直接说结论用一块 ESP32、两三块钱的元件、几十行 MicroPython 脚本就能做出一个带精准 PWM 输出的驱动器既能调 LED 亮度也能控制直流电机转速甚至能给加热丝、风扇、伺服这类设备提供干净的 PWM 信号。我前前后后焊了三版被发热、频率抖动、引脚冲突折腾过不少次这篇文章把最终稳定方案、代码封装和踩坑记录全部摊开讲你可以照抄。1. 项目思路为什么自己折腾一个 PWM 驱动器1.1 这个驱动器能干什么、适合谁PWM 驱动器听起来像是一个很“工业”的玩意其实日常用得非常多。LED 无级调光、灯带呼吸渐变、直流电机调速、风扇温控、电烙铁恒温、蜂鸣器变调甚至给外部大功率模块提供控制信号本质上都要 PWM。市面上能直接买到的 PWM 模块虽然便宜但问题在于你改不了频率改不了分辨率也接不了传感器做闭环。自己用 ESP32 做一个最大的价值不是省那几十块钱而是把 PWM 参数的控制权全握在手里。适合这个项目的人我觉得这么分刚接触 MCU 的新手想搞懂 PWM 到底是什么、引脚怎么用、示波器怎么测手头有 MicroPython 开发经验想从点亮 LED 升级到真正驱动负载的人做智能家居、桌面工控、小型机器人需要一个可编程 PWM 模块作为基础部件的开发者。这个项目不需要你会写底层寄存器不需要焊接贴片元件甚至不需要理论很深。它卡在“会跑 MicroPython 脚本”和“想驱动硬件”之间如果你就处在中间这个位置这个项目刚好填坑。1.2 为什么选 ESP32 加 MicroPython 而不是 Arduino 或 C我最早用 Arduino 的analogWrite()做 PWM代码确实简单但局限很明显analogWrite()是软件模拟 PWM频率固定在 490Hz分辨率只有 8 位而且多路输出时容易相互干扰。后来换成 STM32 用 C 开发PWM 做得漂亮但每次改一个参数要重新编译、烧录、看寄存器手册迭代速度太慢了。ESP32 上跑 MicroPython最香的是三个点硬件 PWM 通道多适合同时驱动多路负载频率、占空比可以在运行时随时修改改完立刻生效不用重新编译代码可读性好写个类、写个循环整个过程就是写 Python 的感觉。有人可能担心 MicroPython 的性能问题。我实测下来PWM 信号是硬件外设生成的MicroPython 只是往寄存器写参数不会参与高频率切换。这意味着就算你写再慢的 Python 代码只要不频繁改参数PWM 输出波形依然稳定。换句话说Python 的慢根本不重要干活的是硬件。2. PWM 原理与关键参数拆解2.1 占空比、频率、分辨率三者的关系与取舍不计较严谨定义的话PWM 就是一个方波高电平持续时间和低电平持续时间的比例决定等效输出电压。拿 LED 打比方频率是闪烁的速度占空比是亮的时间占比。人眼有视觉暂留只要闪烁频率高到一定程度看到的就不是闪而是稳定亮度。三个参数必须一起谈频率单位 Hz每秒钟完成的脉冲周期数。控制 LED 一般用 1kHz 到 5kHz 就够控制直流电机建议 20kHz 以上这样可以避开人的听觉范围减小噪声控制伺服通常也是 50Hz但脉冲宽度本身要变化。占空比一个周期内高电平所占的百分比。0% 是完全关断100% 是完全导通。分辨率占空比能细分到多小的步进。MicroPython 标准库在 ESP32 上面duty()函数的范围是 0 到 1023也就是 10 位分辨率。新版本固件还提供duty_u16()范围是 0 到 65535也就是 16 位。我的习惯是这样的追求平滑调光就尽量拉高 PWM 频率并加大分辨率但需要注意频率越高、分辨率越高MCU 的负担也会增加。ESP32 是双核芯片跑个 5kHz 的 PWM 毫无压力我以前测过即使同时开 8 路 40kHz 输出也不会卡住系统。参数常用范围适用场景频率1kHz ~ 5kHzLED 调光灯带控制频率20kHz ~ 40kHz直流电机调速避开噪声频率50Hz舵机控制占空比0% ~ 100%等效电压控制分辨率10 位或 16 位影响调光平滑度2.2 ESP32 硬件 PWM 的资源分配ESP32 内部有独立的 PWM 外设官方描述叫 LEDC原本设计给 LED 用的但这不影响它驱动其他负载。LEDC 在芯片上总共有 16 路通道分成两组高速组和低速组每组的频率源可以独立设置。有一点必须提醒通道数和引脚数是两回事。通道是 PWM 发生器引脚是物理输出口。一个通道可以绑定到不同的 GPIO但一个 GPIO 同时只能绑定一个通道。MicroPython 封装之后你用PWM(Pin(2))创建对象时固件会自动帮你分配一个空闲的通道。这个透明机制方便却也容易让人忽略了一个问题如果你用同样的方式创建了很多个 PWM 对象底层通道可能不够用。我在项目里同时控制 LED 灯带和风扇总共才 4 路远远没到 16 路的瓶颈。但如果你准备做矩阵灯或者多轴机器人就要提前规划哪几个引脚用在哪几个通道上别等着固件自动分配。我的做法是固定一个引脚优先分配给固定功能项目初始化时一次性创建所有 PWM 对象免得运行到一半才去创建造成不可预测的错误。3. 硬件选型与接线方案3.1 元件清单与选型理由这个项目最基础的版本元件少得可怜全部材料加起来十几块钱ESP32 开发板一块任意带 MicroPython 固件的版本均可一个 360° 旋转的电位器10kΩ 即可用于调节占空比一颗到几颗大功率 LED 或 LED 灯带作为主要负载如果是电机或者大电流设备加一块 MOSFET 驱动模块比如用 N 沟道 MOSFET 加下拉电阻组成一个最简开关电路面包板加杜邦线用来快速搭建和调试如果手头有示波器建议备一根探头没有示波器可以用简单的滤波电路大致判断占空比能不能用。选 ESP32 而不是 ESP8266原因很简单ESP8266 在 MicroPython 下只有软件 PWM多个输出同时工作会让主循环卡死而且 ADC 只有 10 位用来读电位器不够细腻。ESP32 的 ADC 是 12 位读出来的数值范围是 0 到 4095映射到 PWM 占空比时分均匀调光手感好很多。MOSFET 选型上有讲究。控制小电机或者灯带选逻辑电平 MOSFET 比较省心栅极电压只需要 3.3V 左右就能完全导通。我以前拿到一个普通 MOSFET栅极阈值电压太高ESP32 的 3.3V 根本驱动不起来导致电机不转排查了很久才发现是选型错误。所以买的时候别只看型号先看手册里的Vgs(th)参数低于 2V 才能放心用。3.2 接线要点和安全注意事项最简版接线ESP32 的 GPIO25 接到 MOSFET 栅极同时通过 10kΩ 电阻下拉到 GNDMOSFET 源极接 GND漏极接负载一端负载另一端接电源正极如果控制的是感性负载电机、继电器必须在负载两端反向并联一个续流二极管不然断电瞬间会产生高压反跳直接击穿 MOSFET电位器三个引脚两端分别接 3.3V 和 GND中间抽头接 GPIO34GPIO34 是 ADC 输入引脚注意不要复用成 PWM 输出。这里有个新手容易犯的错误负载电源和 ESP32 电源最好分开共地即可。ESP32 不要直接从大功率电源取电否则电机一转电压波动就会导致 ESP32 死机重启。我第一版就是用同一个降压模块给灯带和开发板供电灯带全亮时 ESP32 直接黑屏重启。后面改成独立供电、单点共地问题彻底消失。另外GPIO 引脚不是每个都能安全用于 PWM 输出。ESP32 有的引脚在启动时必须保持特定电平比如 GPIO0、GPIO2、GPIO5 这类如果你接的负载在启动瞬间把电平拉低可能造成无法进入运行状态。我实际使用中最可靠的是 GPIO25、GPIO26、GPIO32、GPIO33 这几个它们没有启动限制也没有板载其他功能冲突用来做 PWM 输出非常稳。注意如果你用的是 3.3V 供电的 LED可以直接用 GPIO 输出 PWM 驱动但驱动 12V 或 24V 灯带时必须经过 MOSFET千万不能把高压引到 GPIO 上。4. MicroPython 代码实现从点亮 LED 到通用 PWM 驱动器4.1 PWM 对象的基础操作MicroPython 在 ESP32 上控制 PWM 很简单基础代码就三行from machine import Pin, PWM pwm_led PWM(Pin(25), freq1000, duty512)这段代码创建了一个频率 1kHz、占空比 512/1023 的 PWM 输出。duty512大约就是 50% 占空比LED 亮度应该是最舒服的状态。运行中修改参数也很直观pwm_led.freq(5000) # 改成 5kHz pwm_led.duty(800) # 改成约 78% 占空比但这里有固件版本差异需要留意。有些新版本 MicroPython 用duty_u16()代替了duty()取值范围也不同duty()0 到 1023duty_u16()0 到 65535duty_ns()以纳秒为单位设置高电平持续时间这个用在舵机上非常合适因为舵机要求的不是占空比而是严格的 1ms~2ms 脉宽。为了避免版本问题我建议在开头写一段自动适配代码先判断对象有没有duty_u16属性有就用它没有就退回duty。这个习惯能省掉很多重烧固件的烦恼。释放 PWM 输出也有讲究。调用deinit()之后引脚会变成输入状态电平不确定。如果你希望它变成确定的低电平最好在deinit()之前先把duty设为 0再调deinit()可以有效避免负载异常动作。4.2 封装一个通用 PWM 驱动类直接写散装代码也能工作但要维护多个 PWM 通道写个类反而更清晰。这个是实战用的封装from machine import Pin, PWM class PWMDriver: def __init__(self, pin_num, freq1000, duty0): self.pwm PWM(Pin(pin_num, Pin.OUT), freqfreq) self.set_duty(duty) def set_freq(self, freq): if freq 0: self.pwm.freq(freq) def set_duty(self, duty): # 支持 0~100 的百分比输入内部自动转换 duty max(0, min(100, int(duty))) if hasattr(self.pwm, duty_u16): self.pwm.duty_u16(int(duty / 100 * 65535)) else: self.pwm.duty(int(duty / 100 * 1023)) def fade_to(self, target, steps50, delay0.02): 渐变到目标占空比 current self._read_duty_percent() step_val (target - current) / steps for i in range(steps): self.set_duty(int(current step_val * (i 1))) time.sleep(delay) def _read_duty_percent(self): # 旧版没有直接读取 duty 的方法需要自己维护状态 return getattr(self, _current, 0) def stop(self): self.set_duty(0)注意_read_duty_percent()这里偷了个懒没有真正从硬件读取当前占空比而是用属性来记录。这是因为 MicroPython 在某些固件版本里没有提供duty()的读取接口强行读取会抛异常。在set_duty里记得更新self._current变量这个类才完整def set_duty(self, duty): duty max(0, min(100, int(duty))) self._current duty if hasattr(self.pwm, duty_u16): self.pwm.duty_u16(int(duty / 100 * 65535)) else: self.pwm.duty(int(duty / 100 * 1023))封装好之后控制灯带就是fan PWMDriver(25, freq20000) fan.set_duty(60) # 60% 转速 fan.fade_to(80, steps30, delay0.05) # 平滑升速这个类我实际用了大半年之后做其他项目也直接复用往上扩展温度检测控制、蓝牙遥控都行不用改底层驱动代码。4.3 完整演示旋钮调光加串口调速下面是一个可以直接运行的整体程序能同时干两件事用电位器平滑调节 LED 亮度通过串口命令调节风扇转速import time from machine import Pin, PWM, ADC class PWMDriver: def __init__(self, pin_num, freq1000, duty0): self._current 0 self.pwm PWM(Pin(pin_num, Pin.OUT), freqfreq) self.set_duty(duty) def set_freq(self, freq): self.pwm.freq(freq) def set_duty(self, duty): duty max(0, min(100, int(duty))) self._current duty if hasattr(self.pwm, duty_u16): self.pwm.duty_u16(int(duty / 100 * 65535)) else: self.pwm.duty(int(duty / 100 * 1023)) def stop(self): self.set_duty(0) adc ADC(Pin(34)) adc.atten(ADC.ATTN_11DB) # 让 ADC 可以读 0~3.3V led PWMDriver(26, freq5000) fan PWMDriver(25, freq20000) def read_pot_percent(): raw adc.read() # 0~4095 return int(raw / 4095 * 100) last_print time.ticks_ms() while True: led.set_duty(read_pot_percent()) if time.ticks_diff(time.ticks_ms(), last_print) 500: print(LED duty:, led._current, %) last_print time.ticks_ms() time.sleep_ms(20)如果你在串口终端里发命令来调速可以在主循环里加一个简单的字符解析import sys if sys.stdin and sys.stdin.any(): cmd sys.stdin.read() if cmd.startswith(F): fan.set_duty(int(cmd[1:].strip()))用这个程序你可以一边转动电位器看 LED 亮度平滑变化一边给风扇输入F80让它跑到 80% 转速整体逻辑干净清楚也方便以后接蓝牙或者Web 控制面板。提示ADC 引脚不能同时当 PWM 用。如果你用了 GPIO34 读电位器就不要再对 GPIO34 创建 PWM 对象否则 MicroPython 不会报错但输出行为完全不可控调试的时候会非常抓狂。5. 信号质量与实测效果5.1 示波器看波形频率与占空比的验证代码写完别急着接大负载先用示波器看看输出信号到底长什么样。把探头接到 GPIO25我就干过这样的蠢事以为是代码问题反复调参最后发现是探头夹错了引脚。所以第一步先确认探头上夹的是目标 GPIOGND 夹子要夹在地线上别只夹在 GND 一侧就完事。用示波器看 1kHz 输出屏幕上是标准方波频率测量值和设定值误差在 1% 以内这是硬件 PWM 的底子。再看占空比set_duty(30)的时候高电平占比应该接近 30%。你可以微调代码观察波形变化很快就能建立“占空比和亮度/转速对应的直觉”。没有示波器也有一套替代方案用一个 10kΩ 电阻和 100μF 电容组成低通滤波器接到 PWM 输出上再用万用表测电容两端的电压。占空比 50% 时输出电压应该接近电源电压的 50%。我用这套土办法验证过 1kHz 输出误差大约在 2% 左右用来做基本判断足够了。波形测量有几个细节值得注意示波器表笔的地夹要尽量短长地线会引入噪声让波形叠加毛刺高频率和一个长面包板导线会造成波形边缘变圆这是寄生电容效应不是代码问题频率超过 40kHz 之后上升沿时间如果超过一个周期波形就完全失真了所以在低压模拟阶段就测出实际最大可用频率。我实际测试过把频率拉到 80kHz波形已经明显被压扁不是完美方波。因此我建议把项目的工作频率限制在 40kHz 以内一方面波形干净另一方面对负载也友好。5.2 驱动不同负载的实测表现用这个驱动器我分别测过三类负载表现如下LED 灯带1kHz 频率下亮度调节非常平滑分辨率 10 位足够肉眼几乎看不出显色差异。唯一要注意的是灯带供电电流如果跑 24V 灯带必须加 MOSFET不能直接用 GPIO 输出。直流小电机模型电机或者散热风扇20kHz 频率下运行很安静没有啸叫。之前试过 1kHz 驱动电机电机发出明显的吱吱声人耳听起来非常烦躁后来查资料才知道电机 PWM 驱动的听觉噪声主要是低频率成分造成的20kHz 以上就好很多。舵机标准舵机需要的是 50Hz 频率、脉宽 1ms 到 2ms 的信号。ESP32 的 MicroPython 可以用duty_ns()直接设置脉宽我实测驱动一个 9g 舵机旋转角度线性度很好没有抖动。电机驱动那一路我在 MOSFET 源极串联了一个 0.1Ω 的采样电阻通过测量压降来估算电流在调试过程中能直观看到负载是否异常。测出来的电流和电机标称值基本吻合说明 PWM 占空比和等效电压的关系完全符合理论。如果你准备把 PWM 信号引到外面的强电设备建议在 ESP32 输出端加一级缓冲器比如 74HC244或者简单的三极管射极跟随器。这样可以避免外部干扰倒灌到 GPIO信号也能传输更远的距离。我的控制板到功率级之间线长大约 30cm加不加缓冲在 1kHz 下区别不大但到 20kHz 之后加了缓冲明显更稳定。6. 常见问题与排查技巧实录6.1 问题排查速查表现象可能原因排查方法LED 不亮引脚接线错或者占空比为 0万用表测 GPIO 电压代码里强制设 50%电机不转MOSFET 栅极驱动电压不够换逻辑电平 MOSFET测栅极电压是否接近 3.3V频率越高电机越烫频率太高导致开关损耗增加降到 20kHz 左右观察发热变化PWM 输出不受控GPIO 被其他外设复用查 Pin 说明换成 GPIO25/26/32/33一接负载 ESP32 重启供电不足或共地问题LED 和 ESP32 分开供电单独共地舵机抖动频率不稳或脉宽超范围用duty_ns()固定脉宽检查是否有代码阻塞duty()报错固件版本改了 API用hasattr判断兼容duty_u16每条我都在实际项目中踩过。比如供电问题是最容易排查又最容易被忽略的问题我用万用表一量负载一启动供电电压瞬时从 5V 掉到 3.2VESP32 当然重启。6.2 我的几条独家避坑经验与建议最后分享几条不是看文档能看出来的经验。第一不要用软件定时器去频繁修改 PWM 占空比。比如你用一个 1ms 的定时器中断不断调duty虽然输出本身是硬件 PWM但 MicroPython 修改参数的指令执行时间并不固定导致最终输出波形会有轻微的不稳定表现出来就是 LED 闪烁或者电机噪声反而更大。最好的方式是让主循环以足够高的速度去更新参数或者干脆用一个线程循环来做而不是用定时器中断打断本来就乱套的调度。第二启动时把占空比初始化为 0再慢慢升上去。我吃过一次亏程序上电后默认 duty 是 0但我之前在上一次的代码里把默认值设成 100结果每次重启灯带和电机都会猛冲一下非常危险。现在我养成了两个极端习惯所有驱动对象初始化时固定duty0然后stop()方法里也把占空比归零并用deinit()释放引脚。第三多路 PWM 时间上尽量错开更新。如果你要同时控制 8 路 PWM建议在循环里不要严格同步改全部通道每路稍微错开 5ms 到 10ms也就是交错式刷新。这个跟电气上所谓的同时变化其实没关系纯粹是避免 MicroPython 在同一个时间片里做大量寄存器写入导致某一路的更新被延迟。实测这种方式的时候灯光渐变非常平滑连续抖动也几乎看不见。第四做产品原型时优先买电位器而不是按钮。按钮实现步进调光代码要处理消抖、长按、连发逻辑复杂度一下就上去了而且手感不可控。电位器加 ADC 是天生模拟控制代码三行就能搞定用户体验还好。等你把功能验证完再决定要不要换成编码器或者触摸方案。第五固件版本不一致导致代码兼容性问题是这个项目最大的隐性坑。我一开始下载的 MicroPython 固件是老版本duty()正常用后来为了加功能刷了新固件结果duty()直接废弃了所有代码都得改。所以要么锁定固件版本不升级要么在代码里写兼容层。我的 PWMDriver 类里用hasattr判断就是这个原因。这个习惯建议你直接抄走。如果你后续想扩展可以往这个驱动器上增加三个方向一是用温度传感器做闭环控制让风扇根据温度自动调速二是加一个小型 OLED 屏显示当前频率、占空比和实测电压三是把控制接口升级为网络接口用浏览器页面实时调节参数。我在第二版里已经加了 OLED 和网页控制整个模块从一块“裸板”变成可以被系统调用的独立驱动单元接继电器、接传感器、接上位机都完全没问题。这省下的后期调试时间比最初做它的时间多得多。