很早之前就想动手制作一个无刷电机(BLDC)控制器,但一直忙于工作。最近终于抽出时间,完成了从画电路板、打样、焊接再到调试的全过程,电机总算是顺利转起来了。这期间遇到了不少问题,上网查资料、自己测量波形,前前后后差不多花了一个月(中间还出差了一周),现在基本搞定,特意写篇总结记录一下。
这块控制器板子尺寸为100*60mm,属于中等大小。采用DC 12V输入,设计最大电流为10A(实际上还没试过那么大的电机,手头的电机电流大约在5-6A左右)。硬件上支持有感(HALL)和无感(反电动势,EMF)两种模式的切换,可以通过外部滑动变阻器调速,并预留了PWM输入、刹车、正反转、USB和UART等接口。
无刷电机基本原理
首先聊聊原理。无刷电机本质上仍然是直流电机,和传统的直流有刷电机原理相同,只是把物理的电刷和换向器(整流子)换成了电子换向电路。


由于去除了电刷的机械摩擦,无刷电机在寿命、静音和最高转速方面都有很大提升。当然,难点就在于如何获取当前转子的准确位置来进行换相,因此衍生出了有感和无感两种方案。
- 有感:在电机端盖内部加装霍尔(Hall)传感器,通常三个传感器相隔30度或60度安装。
- 无感:不依赖额外传感器,依靠检测未通电相(悬浮相)线圈产生的反电动势(Back-EMF)的过零点来推断转子位置(后面会详细讲)。
两者各有优劣。有感方案低速性能好,可以频繁启停和换向;无感方案结构简单、成本低,在航模领域应用非常广泛。
有感(HALL)模式详解
我们先从相对简单的有感模式说起。无刷电机的三相绕组(U, V, W)与交流电有本质区别。它是由三个H桥按照特定的顺序导通来模拟出的“三相”供电,本质上仍是直流电。电机依靠霍尔传感器检测到的位置信号,按固定顺序进行换相,其转速由施加的电压和负载决定。这里要切记:换相频率不由程序任意决定,而由转子位置决定;电压大小才决定转速。常见的调速方法就是调节PWM占空比(即调节平均电压),六步PWM换相是目前最常用的基础算法,当然还有FOC(磁场定向控制)等更高级的算法。
硬件部分,网上已有非常成熟的三相H桥方案。H桥通常由上臂和下臂的MOSFET组成。如果只是简单演示,上臂选用P-MOS,下臂选用N-MOS,驱动电路会很简单,直接用单片机的IO口就能推得动。但低内阻的P-MOS价格较高,难以做大功率。
这也就是为什么商业控制器基本全部采用N-MOS的原因。 但N-MOS存在一个问题:其栅极驱动电压(Vgs)通常需要比电源电压(Vcc)高出数伏才能完全导通。为了简化电路,我采用了IR公司的半桥驱动芯片IR2304。它内部集成了自举升压电路,外部仅需一个续流二极管和一个储能电容即可为高侧MOSFET提供所需的栅极电压。


有感模式的控制相对简单。三个霍尔传感器输出的通常是数字信号,经过简单的分压后可以直接连接到单片机的IO口。

控制方式也简单明了:将三个霍尔传感器信号连接到单片机的外部中断引脚,在中断处理程序中根据三路信号的组合状态(共6种)进行换相。主程序则负责持续检测ADC值(用于调速)、改变PWM占空比,以及执行过流保护等任务。
下面是一个典型的基于STM32的换相代码示例。STM32的高级定时器(如TIM1, TIM8)可以输出多达4路互补型PWM,还能设置死区时间,使用起来非常方便。
switch(step){
case 4: //B+ C-
/* Next step: Step 2 Configuration */
TIM_CCxCmd(BLDC_TIMx, TIM_Channel_1,TIM_CCx_Disable);
TIM_CCxNCmd(BLDC_TIMx, TIM_Channel_1,TIM_CCxN_Disable);
}
/* Channel1 configuration */
/* Channel2 configuration */
TIM_SetCompare2(BLDC_TIMx,BLDC_TIM_PERIOD);
TIM_CCxCmd(BLDC_TIMx,TIM_Channel_2,TIM_CCx_Enable);
/* Channel3 configuration */
TIM_SetCompare3(BLDC_TIMx,BLDC_TIM_PERIOD*speed_duty/1000);
TIM_CCxNCmd(BLDC_TIMx,TIM_Channel_3,TIM_CCxN_Enable);
break;
case 5: //B+ A-
/* Next step: Step 3 Configuration ------------------- */
TIM_CCxCmd(BLDC_TIMx,TIM_Channel_3,TIM_CCx_Disable);
TIM_CCxNCmd(BLDC_TIMx,TIM_Channel_3,TIM_CCxN_Disable);
/* Channel1 configuration */
TIM_SetCompare1(BLDC_TIMx,BLDC_TIM_PERIOD*speed_duty/1000);
TIM_CCxNCmd(BLDC_TIMx,TIM_Channel_1,TIM_CCxN_Enable);
/* Channel2 configuration */
TIM_SetCompare2(BLDC_TIMx,BLDC_TIM_PERIOD);
TIM_CCxCmd(BLDC_TIMx,TIM_Channel_2,TIM_CCx_Enable);
/* Channel3 configuration */
break;
case 1: //C+ A-
/* Next step: Step 4 Configuration ------------------- */
TIM_CCxCmd(BLDC_TIMx,TIM_Channel_3,TIM_CCx_Disable);
TIM_CCxNCmd(BLDC_TIMx,TIM_Channel_2,TIM_CCxN_Disable);
/* Channel1 configuration */
TIM_SetCompare1(BLDC_TIMx,BLDC_TIM_PERIOD*speed_duty/1000);
TIM_CCxNCmd(BLDC_TIMx,TIM_Channel_1,TIM_CCxN_Enable);
/* Channel2 configuration */
/* Channel3 configuration */
TIM_SetCompare3(BLDC_TIMx,BLDC_TIM_PERIOD);
TIM_CCxCmd(BLDC_TIMx,TIM_Channel_3,TIM_CCx_Enable);
break;
case 3: //C+ B-
/* Next step: Step 5 Configuration ------------------- */
TIM_CCxCmd(BLDC_TIMx,TIM_Channel_1,TIM_CCx_Disable);
TIM_CCxNCmd(BLDC_TIMx,TIM_Channel_1,TIM_CCxN_Disable);
/* Channel1 configuration */
/* Channel2 configuration */
TIM_SetCompare2(BLDC_TIMx,BLDC_TIM_PERIOD*speed_duty/1000);
TIM_CCxNCmd(BLDC_TIMx,TIM_Channel_2,TIM_CCxN_Enable);
/* Channel3 configuration */
TIM_SetCompare3(BLDC_TIMx,BLDC_TIM_PERIOD);
TIM_CCxCmd(BLDC_TIMx,TIM_Channel_3,TIM_CCx_Enable);
break;
case 2: //A+ B-
/* Next step: Step 6 Configuration ------------------- */
TIM_CCxCmd(BLDC_TIMx,TIM_Channel_3,TIM_CCx_Disable);
TIM_CCxNCmd(BLDC_TIMx,TIM_Channel_3,TIM_CCxN_Disable);
/* Channel1 configuration */
TIM_SetCompare1(BLDC_TIMx,BLDC_TIM_PERIOD);
TIM_CCxCmd(BLDC_TIMx,TIM_Channel_1,TIM_CCx_Enable);
/* Channel2 configuration */
TIM_SetCompare2(BLDC_TIMx,BLDC_TIM_PERIOD*speed_duty/1000);
TIM_CCxNCmd(BLDC_TIMx,TIM_Channel_2,TIM_CCxN_Enable);
/* Channel3 configuration */
break;
case 6: //A+ C-
/* Next step: Step 1 Configuration ------------------- */
TIM_CCxCmd(BLDC_TIMx,TIM_Channel_2,TIM_CCx_Disable);
TIM_CCxNCmd(BLDC_TIMx,TIM_Channel_2,TIM_CCxN_Disable);
/* Channel1 configuration */
TIM_SetCompare1(BLDC_TIMx,BLDC_TIM_PERIOD);
TIM_CCxCmd(BLDC_TIMx,TIM_Channel_1,TIM_CCx_Enable);
/* Channel2 configuration */
/* Channel3 configuration */
TIM_SetCompare3(BLDC_TIMx,BLDC_TIM_PERIOD*speed_duty/1000);
TIM_CCxNCmd(BLDC_TIMx,TIM_Channel_3,TIM_CCxN_Enable);
break;
default:
TIM_CCxCmd(BLDC_TIMx,TIM_Channel_1,TIM_CCx_Disable);
TIM_CCxNCmd(BLDC_TIMx,TIM_Channel_1,TIM_CCxN_Disable);
TIM_CCxCmd(BLDC_TIMx,TIM_Channel_2,TIM_CCx_Disable);
TIM_CCxNCmd(BLDC_TIMx,TIM_Channel_2,TIM_CCxN_Disable);
TIM_CCxCmd(BLDC_TIMx,TIM_Channel_3,TIM_CCx_Disable);
TIM_CCxNCmd(BLDC_TIMx,TIM_Channel_3,TIM_CCxN_Disable);
break;
下面是实际测量的一些波形图:
-
U, V, W三相霍尔传感器电平及W相PWM波形:

-
U, V, W三相驱动波形及W相霍尔电平:

-
W相上臂常开、下臂PWM、以及W相霍尔信号:

-
IR2304芯片输出的驱动信号,可见上臂驱动电压已明显高于VCC:

无感(反电动势)模式详解
再说说无感模式。由于电机静止时没有反电动势,因此无感启动通常都采用三段式方法:1. 预定位;2. 开环强拉启动;3. 进入闭环反电动势检测运行。
正如网友所说,“江湖一层纸,戳破不值半文钱”。
- 预定位:强制给某一相通电一段时间(例如占空比30%-50%),让电机转子定位到一个已知位置。占空比不宜太大,否则线圈可能发热。
- 启动(开环强拉):按照固定顺序逐步强制换相,并且需要一个加速过程,把电机“拉”起来。这个过程参数很关键:加速太慢会导致电机抖动甚至反转;太快则会丢步,有点像控制步进电机。目标是让电机转起来,直到能产生足够被检测到的反电动势。我参考了德国MK电调的算法,每次换相延时时间比上一次减少
1/25,形成一个平滑的加速过程。
- 闭环运行:当电机转速足够高,反电动势信号清晰稳定后,切换到基于反电动势过零点检测的闭环换相,逻辑就与有感模式类似了。
启动阶段的代码逻辑示例如下:
speed_duty=30; //30% 占空比启动
BLDC_PHASE_CHANGE(Step[Phase]); //先固定在一相上,预定位
Delay_MS(200);
speed_duty=pwm; //设定运行占空比
timer = 300; //初始换相延时较大
while(1)
{
for(i=0;i<timer; i++)
{
Delay_US(120); //等待
}
timer-= timer/25+1; //每次换相后,延时时间递减,实现加速
if(timer < 25)
{
if(TEST_MANUELL)
{
timer = 25; //保持在开环强制换向(测试用)
}
else
{
bldc_dev.motor_state=RUN; //切换到运行状态(闭环)
break;
}
}
Phase++;
Phase %= 6;
BLDC_PHASE_CHANGE(Step[Phase]); //执行换相
}
反电动势检测原理
说到反电动势,可能很多人不太明白。先思考一个问题:电机线圈内阻通常很小(比如0.2欧姆),施加的电压假设为10V,那电流岂不是应该达到 10V / 0.2Ω = 50A?为什么电机不会立刻烧毁?
其实,电机线圈在通电的瞬间,并不是纯粹的电阻负载。因为线圈在磁场中运动(或即将运动)会产生一个与电源电压方向相反的反电动势(Back-EMF)。假设这个反电动势是9.8V,那么实际在线圈上产生电流的净电压就只有 10V - 9.8V = 0.2V,电流约为 0.2V / 0.2Ω = 1A,这就合理了。
这里涉及到初中的物理知识——法拉第电磁感应定律和右手定则:当导体切割磁感线时,会产生感应电动势。

如下图所示,当A、C两相通电(假设A接+12V,C接GND)时,B相是悬浮不通电的。在静止状态下,三相星形连接的中点电压理论上是电源电压的一半(6V)。但当转子转动时,悬浮的B相线圈在切割磁场,就会产生感应电动势。这个电动势的大小和极性,取决于B相线圈相对于磁场N、S极的位置。

利用这个特性,我们不就可以间接获得转子的位置了吗? 是的,关键就是检测这个悬浮相的电动势何时经过“中点电压”(即过零点)。
检测电路网上有很多成熟方案。如下图所示,通常需要在4.7kΩ的下拉电阻上并联一个100nF左右的电容,构成一个简单的低通滤波器来平滑毛刺。当然,滤波也可以在软件中完成。

我们的目标就是检测悬浮相电压相对于中性点(N)的过零点。
网上常用的两种检测方法:1. 单片机ADC采集;2. 使用电压比较器(如LM339)。 我选择了比较器方案,因为LM339价格已经非常便宜,在高速响应方面比ADC有明显优势。只需要比较各相输入(Cin, Bin, Ain)与中性点(N)的电压差,即可得到数字化的过零信号。
理想情况下,三相的反电动势波形和换相点应该是非常规整的,如下图所示:

但现实很残酷,实际电路中根本得不到这么完美的波形。下图已经是调理得比较好的信号了,但仍然存在大量毛刺。如果直接用这样的信号触发单片机中断,肯定会引起误换相,严重的会导致上下桥臂直通而烧毁MOS管。


为什么会有这些毛刺?有些还很有规律。参考相关资料,这与功率MOSFET关断时线圈中储能释放引起的“消磁”过程有关。

我们不必深究其物理原理,只需要知道这个干扰持续时间很短,可以通过软件滤波将其消除。我的做法是开启一个定时器中断(周期20us),在中断服务程序中对比较器输出的原始信号进行数字滤波和边沿检测。
主要处理逻辑如下:
const unsigned int FilterNums = 0xff;
static unsigned int nums =0;
static unsigned int Queue_UStatus =0;
static unsigned int Queue_VStatus =0;
static unsigned int Queue_WStatus =0;
static unsigned char EMF_SVal =0;
unsigned char Filter_U_Status=0;
unsigned char Filter_V_Status=0;
unsigned char Filter_W_Status=0;
unsigned char EMF_Val=0;
unsigned int status_h;
unsigned int status_l;
unsigned int Delay30deg =0;
/* 清除中断标志位 */
if ( TIM_GetITStatus(TIM3 , TIM_IT_Update) != RESET )
{
TIM_ClearITPendingBit(TIM3 , TIM_FLAG_Update);
}


这段代码的核心思想是:将连续多个采样点的状态存入一个队列(移位寄存器),只有当连续一定数量(FilterNums对应位全为1或全为0)的采样点状态一致时,才更新滤波后的状态值,从而滤除短暂的毛刺干扰。检测到稳定的过零边沿后,再延时30度电角度进行换相。
关于这个“延时30度换相”,网上有讨论说会影响效率。我实际测试了一下,没有发现明显差异。也有说法称在大功率电机下不延时反而运行更平滑。究竟哪种方式更优,还有待大家根据实际电机和应用场景去验证。对于嵌入式系统与单片机的深入学习,可以关注云栈社区的相关讨论。
成果展示
最后,展示几张控制器驱动不同电机成功运行的照片。
-
整体测试台,驱动一个电机:

-
电路板特写:

-
(驱动硬盘电机,无感模式)
-
驱动一个电动工具电机(有感模式):

-
为控制器加装散热片后的样子:

这次从零开始造轮子的经历让我对无刷电机的驱动原理有了更深刻的理解。硬件设计、软件调试、问题排查的完整流程,其收获远大于直接使用现成的模块。希望这篇总结能给也想自己动手制作无刷电机控制器的朋友提供一些参考。