☰
STM32F103串口控制PWM调节LED亮度:从原理到实战
2026/10/8 21:25:52 网站建设 项目流程

简介:这套STM32F103串口接收控制PWM调光的完整工程,面向嵌入式初学者与进阶开发者,解决通过串口指令实时调节LED亮度的问题,并集成STemWin轻量级GUI,便于在上位机或触摸屏上直观控制。压缩包共131个文件,以C源码和H头文件为主,包含USART、PWM、LCD驱动等模块化程序,另有工程配置文件、链接脚本及清理脚本,整体仅1.25MB,结构清晰,可直接导入Keil工程编译学习。已有5598人学习下载。资源完整覆盖USART中断接收与数据解析、定时器PWM占空比映射、STemWin界面资源及滑动条事件回调等实现细节,读者可对照源码理解外设初始化顺序、寄存器配置和主循环调度方式,掌握串口通信、PWM调光与GUI联动开发的整体流程,既能用于课程设计,也能为智能照明和物联网终端的原型验证提供参考。 很多朋友做完流水灯之后,下一站多半会卡在同一个问题上:我已经能让 LED 按顺序亮了,可怎么让电脑这边的指令去操控硬件?这个问题的标准答案,就是今天要聊的项目:STM32F103 通过串口接收数据,再用 PWM 输出调节 LED 亮度。别小看这个 DEMO,它背后把 GPIO 复用、定时器 PWM、串口中断、协议解析这四块嵌入式开发里出场率最高的内容串到了一起,而且全程只需要一块 STM32F103 最小系统板、一个 USB 转串口模块和一个 LED,投入极低,收益却很高。

1. 为什么拿这个项目练手:一条指令穿起四大外设

1.1 从“单向输出”到“输入-处理-输出”的跨越

流水灯其实是“单向输出”,CPU 只管按顺序改 GPIO 寄存器的值,走完就结束了,整个过程没有外界的反馈和干预。而串口控制 LED 这件事,是完整的“输入+处理+输出”链路:数据从 PC 端发出,经过电平转换进到 MCU 的串口外设,CPU 在中断里拿到字节,解析出含义,最后转化成定时器 PWM 的占空比,LED 的亮度随之改变。

这一步看起来简单,但它的本质已经和真实产品里“远程控制一台设备”的逻辑完全一致了,只不过把执行机构从大电机、屏幕、继电器简化成一颗 LED。很多人学完库函数、看完手册,依然写不出一个像样的项目,缺的就是这种把离散知识点串成链路的能力。

1.2 数据流拆解:从串口字节到 LED 亮度的完整路径

我习惯先画数据流,再写代码。这条链路的每一段,都对应一个可以单独调试的模块:

PC 串口助手 → USB 转 TTL 模块(CH340)→ STM32F103 的 USART1_RX(PA10)→ 串口中断接收一个字节 → 协议解析得到目标占空比 → 通过 TIM2_CH1(PA0)输出 PWM → 限流电阻 → LED

拆开以后你会看到,每一段都可以单独验证。串口有没有数据进来,用调试助手看回显;PWM 波形对不对,用万用表量 PA0 的平均电压,或者用示波器直接看占空比;LED 亮不亮,肉眼就能判断。这样做的好处是,万一出了故障,不需要对着整个工程从头猜,数据走到哪一段断了,问题就出在哪一段。

2. 硬件准备与接线:一半的坑都发生在这里

2.1 选型清单与驱动注意点

硬件清单非常平民化:

  • STM32F103C8T6 最小系统板,也就是常说的蓝丸或者黑丸。
  • USB 转 TTL 模块,最常用的是 CH340,驱动去芯片厂商页面下载对应版本,装完以后在设备管理器里确认串口号,比如 COM3。
  • LED 一个,330Ω 左右电阻一个,面包板加杜邦线若干。
  • BOOT0 跳线帽拉到 GND,让芯片从 Flash 启动。

接线表我直接列出来,照着插就行:

模块引脚STM32F103 引脚说明
USB-TTL 的 TXDPA10(USART1_RX)交叉连接,发送对接收
USB-TTL 的 RXDPA9(USART1_TX)交叉连接,接收对发送
USB-TTL 的 GNDGND必须共地
USB-TTL 的 3.3V3.3V可选,给板子供电
PA0(TIM2_CH1)通过电阻接 LED 正极LED 负极接 GND

这里有一个常见误区:TXD 接 PA10、RXD 接 PA9,是交叉接,不是直连。很多人第一次插反了,发现串口助手发数据完全没反应,还以为代码写错了。另外,GND 一定要共地,USB 转 TTL 和 STM32 板子各供各的电时,地线不连上,电平根本没有参考点,通信必然失败。

2.2 限流电阻的计算逻辑

LED 不能直接跨在 PA0 和 GND 之间,否则电流会超出引脚和 LED 的承受范围。红色 LED 的正向压降大约 2V,PA0 在推挽输出模式下高电平是 3.3V,那么限流电阻上的压降就是 3.3 - 2 = 1.3V。如果想让电流在 10mA 左右,电阻值就是 1.3V / 0.01A = 130Ω。

实际用 220Ω 或 330Ω 都行,电流小一点,亮度依然可见,还能省电、减小发热。我在调试时常用 330Ω,因为哪怕占空比拉到 100%,电流也不到 10mA,对板载 LDO 的压力很小。顺带提醒一句:这个项目里所有电路都用 3.3V 这一侧,别把 LED 直接接到 USB 的 5V 上,引脚电平匹配和器件耐压都要留余量。

3. 时钟、串口、定时器:CubeMX 里最容易忽略的三个设置

3.1 时钟树:为什么你算出来的 PWM 频率总不对

打开 STM32CubeMX,选好芯片型号之后,第一件事是配置时钟树。如果板子上有 8MHz 外部晶振,就用 HSE 作为时钟源,把 SYSCLK 拉到 72MHz。如果没有外部晶振,或者你想省电、省元件,也可以把时钟源切到 HSI 内部 8MHz 振荡器,同样能跑到 72MHz,只是精度略差。

这里有个绝大多数新手都踩过的坑:F103 的定时器时钟并不直接等于 APB1 总线时钟。默认配置下 APB1 是 36MHz,但定时器时钟会在此基础上自动乘以 2,变成 72MHz。如果你拿着 APB1 的 36MHz 去算 PWM 频率,算出来的结果会差一倍,配出来的实际频率和你预期的完全对不上。

PWM 频率的计算公式是:

F = 定时器时钟 / (PSC + 1) / (ARR + 1)

我把 TIM2 的预分频 PSC 设为 71,自动重装值 ARR 设为 999,那么算出来就是 72MHz / 72 / 1000 = 1kHz。分辨率就是 1/1000,也就是占空比最小步进是 0.1%。LED 亮度控制用 1kHz 足够了,人眼在这个频率下感受不到闪烁;再低一点到两三百赫兹,LED 就会明显闪,这也是很多自己搭 PWM 的项目看起来“灯在闪”的原因。

3.2 串口和定时器的参数配置

CubeMX 里的配置项其实就几处:

  • RCC:HSE 选 Crystal/Ceramic Resonator。
  • SYS:Debug 选 Serial Wire,保证板载 ST-Link 还能下载调试。
  • USART1:模式选 Asynchronous,波特率 115200,数据位 8,停止位 1,无校验,打开全局中断。
  • TIM2:Clock Source 选 Internal Clock,Channel1 选 PWM Generation CH1,PSC 写 71,ARR 写 999,初始 Pulse 写 0。
  • PA0 和 PA9、PA10 的 GPIO 模式,CubeMX 会自动根据外设配置设置好,不需要手动改。

这里再解释一下为什么用 TIM2 的通道 1。TIM2_CH1 的默认复用引脚是 PA0,和串口用的 PA9、PA10 不冲突,接线方便,而且 TIM2 是 16 位定时器,ARR 最大可以到 65535,对于 LED 亮度控制来说分辨率空间非常充足。用 TIM1 的 PA8 也可以,但对新手来说 TIM2 的默认映射更省心。

4. 串口协议设计:先想清楚“一字节”还是“带帧的报文”

4.1 入门方案:一字节直接映射占空比

最直接的思路是:串口每收到一个字节 0x00 ~ 0xFF,直接把它作为占空比数值写入定时器的比较寄存器。如果 ARR 是 255,那么发送 0 就是全灭,发送 255 就是全亮,中间值就是对应的灰度。代码量极小,十几行就能跑通。

这个方案的缺点是显而易见的:没有帧边界,也没有校验。万一数据在传输过程中被干扰,或者串口助手设置了“发送新行”,末尾多出 0x0D 0x0A,那 LED 就会莫名其妙地跳到一个奇怪亮度。但作为第一次跑通整个链路,我强烈建议先用这种方式做验证,目的不是做产品,而是确认“串口→中断→PWM→LED”这条路上没有硬件问题。

4.2 实用方案:带帧头、数据和校验的报文

链路通了以后,就该升级协议了。我常用的格式很简单,可读性好,串口助手调试也方便:

格式:$PWM:xxx\r\n

其中 xxx 是 0~100 的十进制数字,表示百分比亮度。$PWM是帧头,:后面跟数据,\r\n是帧结束符。这样设计有几个考虑:帧头让解析程序能识别“一条指令从哪开始”,结束符让程序知道“一条指令在哪里结束”,中间的数据段固定是 0~100 的整数,方便转换成占空比。

如果将来要上更复杂的系统,比如同时控制多个参数,再把协议扩展为$PWM:1,128;RGB:2,64,128,200\r\n这类结构化报文也不难。核心思想是一样的:帧头定位、数据解析、结束符收尾。

至于校验,简单场景可以不放在协议里,但如果你用无线模块或者在工业环境调试,建议加一个累加和校验字节,把所有数据字节累加取低 8 位放在帧尾,接收端做同样的累加比对。这样哪怕偶尔串进一个干扰字节,也能立刻知道这一帧是错的,直接丢弃,而不是让设备执行一个错误指令。

5. 核心代码实现:中断接收、解析状态机与 PWM 输出

5.1 先让 PWM 跑起来

无论用哪种协议,第一步都是先启动 PWM,并设置一个初始占空比。HAL 库的写法是:

HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 0);

第一行启动 TIM2 通道 1 的 PWM 输出,第二行把比较值设为 0,也就是初始状态 LED 全灭。注意__HAL_TIM_SET_COMPARE是一个宏,直接操作定时器的 CCR 寄存器,第二个参数的范围必须在 0 到 ARR 之间。我之前的 ARR 是 999,所以这个值合法范围就是 0~999。

对应到标准库,就是:

TIM_SetCompare1(TIM2, 0);

逻辑是一样的,本质上都是往 CCR1 寄存器写值。

5.2 串口中断接收与回调

HAL 库的串口接收用起来很顺手,核心就两步:

先声明一个全局变量,在主函数开始处调用:

uint8_t rx_byte = 0; HAL_UART_Receive_IT(&huart1, &rx_byte, 1);

这句的意思是:让 USART1 在中断模式下接收 1 个字节,收到以后存到 rx_byte 里,然后自动调用回调函数。

回调函数里写处理逻辑:

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { parse_char(rx_byte); HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }

这里有一个新手容易漏掉的细节:HAL_UART_Receive_IT是一次性的,收完一个字节以后,中断配置就失效了,所以必须在回调里再次调用它,让串口继续等待下一字节。忘了这一步,现象就是“第一次发数据有反应,之后再发就没反应了”。

5.3 解析状态机:把字节流变成亮度指令

如果采用一字节方案,回调里直接写寄存器就行:

__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, rx_byte * 999 / 255);

注意这里做了一个换算。串口发来的 0xFF(255)对应占空比 100%,而 ARR 是 999,所以要把 255 映射到 999 的范围。不做换算的话,串口发 255,PWM 比较值只有 255,亮度只到 25.5%,看起来就是“调到最大也不够亮”。

如果采用$PWM:xxx\r\n这种帧格式,我写了一个简洁的状态机:

typedef enum { ST_IDLE, ST_HEAD1, ST_HEAD2, ST_DATA } ParseState; ParseState state = ST_IDLE; char buf[4]; uint8_t idx = 0; void apply_duty(uint8_t percent) { uint16_t compare = (uint16_t)(percent * 999 / 100); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, compare); } void parse_char(uint8_t c) { switch (state) { case ST_IDLE: if (c == '$') state = ST_HEAD1; break; case ST_HEAD1: state = (c == 'P') ? ST_HEAD2 : ST_IDLE; break; case ST_HEAD2: if (c == 'W') { idx = 0; state = ST_DATA; } else state = ST_IDLE; break; case ST_DATA: if (c >= '0' && c <= '9') { if (idx < 3) buf[idx++] = c; } else if (c == '\r' || c == '\n') { buf[idx] = '\0'; uint8_t percent = (uint8_t)atoi(buf); if (percent > 100) percent = 100; apply_duty(percent); idx = 0; state = ST_IDLE; } else { idx = 0; state = ST_IDLE; } break; } }

状态机的逻辑并不复杂,它有点像流水线:每个字节进来,先判断当前处于哪个状态,再决定是跳转到下一个状态还是回到空闲态。$PWM三个字节必须连续正确,才能进入数据接收状态。在数据状态里,数字字符被逐个存进缓冲区,最多存 3 位,遇到回车或换行就解析数据。

有一点要提醒:atoi在中断回调里用,虽然这个项目规模小问题不大,但在正式产品里,中断里尽量少做耗时操作,更好的做法是先把原始字节存进环形缓冲区,在主循环里做解析。如果你以后要处理高波特率、大量数据,一定要尽早养成这个习惯。

6. 实测中的典型故障与完整排查链路

6.1 常见现象、原因和处理方式

我把实际调试中遇到过的几个典型问题整理成一张表,方便你对照排查:

现象常见原因排查方向
串口助手显示乱码波特率不一致,或时钟树配置错误检查 PC 端波特率是否 115200,检查 CubeMX 时钟树
发数据 LED 完全没反应TX/RX 没交叉、GND 没共地、串口助手发送的不是 Hex先用万用表测 PA10 电平,再检查接线
串口发 255 但亮度不够占空比数值没有映射到 ARR 范围检查比较值是否做了 0~255 到 0~999 的换算
第一次收数据有反应,之后失效回调里没有重新调用HAL_UART_Receive_IT检查回调函数是否递归调用了接收接口
接收一段时间后任务卡死串口溢出错误 ORE 标志未清除在 ErrorCallback 里清标志并重新开启接收

6.2 一个完整的真实排查过程

有一次我在调试这个项目,用串口助手发送“255”,LED 没反应。我先没看代码,而是用万用表量 PA0 的电压,发现一直是 0V,说明 PWM 输出就没起来。然后我回头查初始化,发现 TIM2 的时钟源在 CubeMX 里没勾选 Internal Clock,导致定时器压根没跑,PWM 自然不会有输出。

还有一次,我串口助手下发$PWM:50\r\n,LED 亮度纹丝不动,但用$PWM:50就正常。问题出在自动发送新行的选项上:串口助手发送时默认勾选了“发送新行”,每次都多发一个\n。我的状态机里遇到\r就解析执行,\n被当成了新的空闲数据,逻辑上没问题,但我在数据缓冲区里没有正确处理\r\n连发的情况。后来我把结束符条件改成同时兼容\r和\n,问题就解决了。这个经验说明,串口调试助手的设置项,本身就是调试内容的一部分,不要忽略。

6.3 稳定性相关的几件小事

  • 串口溢出标志一定要处理。HAL 库在接收速度过快、中断处理不过来时,会置上 ORE 错误标志,如果不处理,会导致后续接收全部失效。我一般会在错误回调里做清理:
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { __HAL_UART_CLEAR_OREFLAG(&huart1); HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }
  • 电源要稳。STM32F103 最小系统板上的 3.3V LDO 电流输出能力有限,如果项目里同时驱动多个 LED 或者舵机、传感器,最好用外部稳压供电,并且和 USB 转 TTL 模块共地。
  • 串口助手的“发送新行”选项要心里有数。调试协议时,建议先关掉这个选项,确保你发出去的字节完全是你想发的字节,等协议调试稳定了,再考虑收尾符的处理。

写在最后:我个人的几点习惯

这类项目我做过很多次,现在每次拿到一个新的 MCU 开发板,第一件事就是先跑通一个类似“串口控制 PWM”的最小链路。因为它能快速验证三件事:开发环境好不好用、串口驱动有没有问题、定时器外设是否正常。链路通了,后续加传感器、加屏幕、加控制算法,都有了可靠的基础。

调试过程中最大的心得是:别一上来就在中断里堆业务逻辑。先用最简单的一字节方案把链路跑通,再用帧协议做结构化控制,最后再考虑加校验、加缓存。每一步只引入一个变量,出了故障才能快速定位。这个习惯帮我省下了大量排查时间,也推荐给你试试。

本文还有配套的精品资源,点击获取

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

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

立即咨询