简介:一套基于STM32F103微控制器的串口接收控制PWM调节LED亮度完整工程源码,面向嵌入式初学者与物联网产品开发者,解决上位机通过串口下发数据实时改变LED亮度的典型需求。压缩包共131个文件,大小仅1.25MB,其中108个头文件与14个C源文件构成核心代码,另有Keil工程文件、汇编启动文件、库文件、文本说明等,结构规整,可直接在MDK中打开编译调试。代码覆盖USART串口中断接收与数据解析、定时器PWM输出配置、STemWin图形界面中滑动条控件的创建与事件回调,以及底层LCD驱动与GUI_X移植层,可清晰看到从接收亮度指令到修改比较寄存器占空比的完整链路。已有5598人次浏览学习,工程具备良好模块化拆解,适合需要快速搭建串口-PWM控制链路或拓展STemWin GUI的开发者移植复用。
1. 串口控制PWM调光,STM32这三个外设是如何配合的
1.1 项目需求拆解:串口、定时器、LED各自扮演什么角色
很多人学STM32都是从点灯开始的,但点完灯往往不知道下一步该做什么。这个项目——串口接收控制PWM调节LED亮度,刚好把点灯这件事往工程方向推进了一步:有输入(串口命令)、有输出(PWM波形)、中间还夹着协议解析和参数换算。做完这个,你会对STM32F103的串口、定时器、GPIO复用这些基础外设有一个整体认识,而不是只会在main函数里翻转电平。
需求一句话就能说清:PC或手机通过USB转TTL给STM32F103发一串文本,比如PWM=60,LED亮度就变成60%。再发PWM=25,LED变成25%。就这么简单,但它背后拆出来有三个独立任务:
第一,串口通信。板子要能收到外部发来的数据,并且能解析出“PWM=60”里的60。这里涉及串口初始化、中断接收、缓冲区设计、结束符判断,任何一个环节处理不好,命令就会丢或错。
第二,定时器产生PWM波形。决定了LED的亮度是通过占空比实现的,而不是GPIO高低电平那种“要么全亮要么全灭”的状态。占空比怎么映射到定时器的比较寄存器,这里有一个计算过程。
第三,协议与逻辑联动。收到命令之后怎么判断命令合法、怎么提取数值、怎么把数值换算成定时器比较值,最后写入寄存器,让LED亮度真正变化。
这套流程放到一个完整的工程项目里,就是“上位机下发指令→下位机解析响应→执行机构动作”的标准雏形。理解了它,后面做电机调速、舵机控制、恒流驱动调光,基本就是换个外设、换个参数的事。
1.2 硬件选型与接线清单
芯片选STM32F103C8T6,这是目前最容易买到、资料最多、也最便宜的一款Cortex-M3芯片。48脚封装,64KB Flash,20KB RAM,3个串口,3个通用定时器加1个高级定时器,做这种小项目绰绰有余。搭配几块钱的“最小系统板”,焊排针就能直接插面包板。
我这边用的硬件清单如下:
| 硬件 | 说明 |
|---|---|
| STM32F103C8T6最小系统板 | 蓝色板或类似的都能用,注意PA0上是否接了板载按键跳线 |
| ST-Link V2 | SWD接口下载程序,四根线:SWDIO、SWCLK、GND、3.3V |
| USB转TTL模块 | 选CH340芯片的比较稳妥,装好驱动即可 |
| 面包板 + 杜邦线 | 连接最小系统板、LED、USB转TTL模块 |
| 5mm红色LED | 红色LED正向压降约1.8~2V,用3.3V供电时压差足够,容易点亮 |
| 220Ω~330Ω电阻 | 用作LED限流电阻,电流控制在4~10mA左右 |
接线方式也很直接。USB转TTL模块的TXD接单片机PA10(USART1_RX),RXD接PA9(USART1_TX),GND必须共地。PA0是TIM2_CH1的PWM输出脚,通过限流电阻接LED阳极,LED阴极接GND。下载器ST-Link占用SWD口,不影响串口使用。
这里有个注意点:PA0在部分“Blue Pill”类型的最小系统板上会通过跳线连到按键,使用前要把相关跳线帽拔掉或避开这个引脚,否则外部电路会干扰PWM信号。如果你手上只有白色/蓝色的高亮LED,正向压降可能到3.0~3.2V,3.3V供电下压差只剩0.1V,此时220Ω电阻基本不亮,建议换红色LED,或者改用三极管驱动。
2. PWM调光到底怎么调,定时器参数从头算起
2.1 PWM调光的物理原理与频率选择
PWM的本质是一个固定频率的方波,核心参数是占空比,也就是高电平时间占整个周期的百分比。LED的亮度由流过它的平均电流决定,而平均电流又由方波的平均电压决定。占空比60%,灯上平均电压就是高电平电压的60%,亮度近似就是最大亮度的60%。
这个原理可以类比成快速开关灯:如果开关频率足够高,眼睛根本来不及分辨每一次明暗交替,看到的就是一个“变暗了的灯”。这也是为什么PWM频率不能太低——低于50Hz,人眼会明显看到闪烁,也就是常说的频闪。工程上做LED调光,1kHz到10kHz是比较常用的范围。再高了LED本身响应跟得上、亮度也能正常出来,但开关损耗和电磁干扰会上升;PCB布线或长线驱动时波形也可能出现振铃。
在STM32F103上做LED调光,我习惯把PWM频率设在1kHz。这个频率既远超人眼闪烁感知阈值,又不会因为频率过高给GPIO和线路带来额外压力,同时参数计算时还特别整齐,后面你会看到。
2.2 用72MHz时钟推算出PSC和ARR
STM32F103的定时器参数计算是整个项目里最需要动脑筋的地方。先看一个关键前提:F103的外部晶振通常是8MHz,通过PLL倍频到系统主频72MHz。APB1总线最高36MHz,但定时器很特殊,当APB1预分频设置为2时,挂在APB1上的TIM2/TIM3/TIM4会获得一个2倍频的时钟,也就是72MHz。
CubeMX里配置好时钟树后,TIM2的输入时钟就是72MHz。PWM输出频率公式是:
f_pwm = TIM_CLK / ((PSC+1) * (ARR+1))其中PSC是预分频值,ARR是自动重装载值。我想要1kHz输出,取PSC=71,那么:
(ARR+1) = 72MHz / 72 / 1kHz = 1000 ARR = 999这样计数器的计数频率是1MHz(也就是1微秒加1),计满1000个数需要1毫秒,PWM周期正好1kHz。占空比调节的分辨率是1/1000=0.1%,也就是说我可以把LED亮度分成1000个等级,对人类眼睛来说已经非常细腻了。
这里有个容易混淆的点:PSC要减1,ARR也要减1。CubeMX里填写的就是71和999,而不是72和1000。因为寄存器值从0开始计数,PSC=71意味着实际分频是72分频,ARR=999意味着自动重装载值是1000。
2.3 串口命令协议:为什么从PWM=60开始
串口本身只负责收发字节,它不知道你发的是命令还是乱码。要让单片机识别命令,必须定义一套双方都认可的协议。协议设计越简单越不容易出错,但也别太简单导致没法扩展。
我用的协议是文本格式:
PWM=60\r\nPWM=是固定前缀,60是0到100的整数,\r\n是回车换行结束符。选择这种格式有几个原因:
第一,直接用串口调试助手就能测试,不需要把数据转成十六进制再发,人眼可读性极强。如果协议用二进制(比如0xAA 0x3C 0x0F),你不查表根本不知道发了什么内容,排查问题很痛苦。
第二,文本协议天然兼容串口助手的“发送新行”功能。你在串口助手里勾上“发送新行”,它会自动在命令末尾追加\r\n,单片机收到后就能判断一条命令结束了。
第三,将来扩展也容易。比如加通道编号变成PWM=1:60,加亮度渐变时长变成FADE=1:60:3000,解析逻辑只增加,不推翻。
为什么命令末尾要有结束符?因为串口接收是一个字节一个字节进来的,接收方必须知道“数据什么时候算完”。最笨的办法是固定长度,比如规定总是发8个字节,但那样不方便。更合理的方式是用特殊字符标记结束,\r\n是业界最通用的行结束符,串口助手、终端工具、单片机代码都认。
3. CubeMX初始化到代码落地的完整过程
3.1 CubeMX关键配置步骤
这个项目我推荐用STM32CubeMX生成初始化代码,然后用HAL库写逻辑。CubeMX的图形化配置能把时钟树、引脚复用、外设参数这些容易出错的部分一次性处理好,比手工翻参考手册初始化寄存器快得多。
在CubeMX里,配置步骤可以归纳如下:
第一步,新建工程选择STM32F103C8T6。RCC选项里选择Crystal/Ceramic Resonator,启用外部8MHz晶振。SYS里Debug选择Serial Wire,这样才能正常使用ST-Link的SWD调试接口。
第二步,配置时钟树。外部HSE 8MHz,PLL倍频到72MHz,APB1预分频为2。配置完成后在Clock Configuration页面确认APB1 Timer Clocks显示为72MHz,这个数值直接影响后面的定时器参数。
第三步,配置TIM2。时钟源选Internal Clock,Channel1选择PWM Generation CH1。参数区填写Prescaler=71,Counter Period=999,Pulse初始值可以设500(对应50%占空比)。自动重装载预装载(Auto-reload preload)建议Enable,这样修改比较值时不会在周期中间被打断,输出更平滑。输出极性选High。
第四步,配置USART1。模式选Asynchronous,波特率115200,字长8位,无校验,1位停止位。引脚默认PA9/PA10。
第五步,生成工程代码。Project Manager里选择生成单独的.c/.h文件,Toolchain选择MDK-ARM或IDE对应选项。生成后打开工程,补充业务逻辑。
打开生成的main.c,你会看到CubeMX已经把GPIO、TIM2、USART1的初始化代码都生成好了。这时候项目其实已经做完了一大半,后面只需要加接收逻辑和业务逻辑。
3.2 串口中断接收:一字节一中断的设计取舍
串口接收有三种方式:轮询、中断、DMA。轮询会在主循环里空转等待数据,浪费CPU;DMA适合接收大块或者不定长数据,但要配合空闲中断IDLE使用,调试起来对新手不太友好。所以我选“每收一个字节触发一次中断”的方式,代码简单、逻辑清晰,完全满足命令帧很短的应用场景。
在主循环之前,先启动接收:
uint8_t rx_byte = 0; uint8_t rx_buf[64]; uint8_t rx_len = 0; volatile uint8_t cmd_ready = 0; HAL_UART_Receive_IT(&huart1, &rx_byte, 1);然后重写中断回调函数:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { if (rx_byte == '\r' || rx_byte == '\n') { if (rx_len > 0) { rx_buf[rx_len] = '\0'; cmd_ready = 1; rx_len = 0; } } else { if (rx_len < 63) { rx_buf[rx_len++] = rx_byte; } } HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }这段代码里有两个细节特别值得说。
第一,为什么收到\r或\n都要结束?因为不同的串口工具和终端软件发出来的结尾不一样,有的发\r\n,有的只发\n。既然都兼容,就把两种都当作结束符。同时只处理rx_len > 0的情况,这样\r\n连续到达时,\n会被静默忽略,不会重复触发命令解析。
第二,为什么在回调里要再次调用HAL_UART_Receive_IT?HAL库的HAL_UART_Receive_IT接收指定字节数(这里是1字节)后会清除接收状态并触发回调。如果不在回调里重新启动,串口就只能收到第一个字节,之后再也不进中断了。这个坑几乎每个用HAL的新手都会踩一次。
另外,中断回调里只做“把数据放进缓冲区、标记命令就绪”这两件事,没有做任何耗时的解析操作。解析放到主循环去,这是中断编程的基本原则——中断里越短越好。
3.3 主循环解析与亮度联动
主循环的逻辑很直白:等待cmd_ready标志,一旦置位就解析缓冲区里的命令,然后设置PWM比较值。这里我没有用sscanf,因为sscanf和sprintf这类格式化函数会引入不小的Flash开销,嵌入式里手写一个轻量解析函数更合适。
uint8_t parse_duty_value(const char *buf, uint8_t *duty) { if (buf[0] != 'P' || buf[1] != 'W' || buf[2] != 'M' || buf[3] != '=') { return 0; } uint8_t value = 0; uint8_t i = 4; while (buf[i] >= '0' && buf[i] <= '9') { value = value * 10 + (buf[i] - '0'); if (value > 100) { return 0; } i++; } if (i == 4 || buf[i] != '\0') { return 0; } *duty = value; return 1; }这个解析函数的要求很严格:必须完整匹配PWM=xxx格式,数字长度至少一位,且整条命令必须到字符串结束才合法。如果发PWM=50abc会被判定非法,避免协议歧义。
主循环里这样处理:
while (1) { if (cmd_ready) { uint8_t duty = 0; if (parse_duty_value((char*)rx_buf, &duty)) { __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, (uint16_t)duty * 10); HAL_UART_Transmit(&huart1, (uint8_t*)"OK\r\n", 4, 100); } else <p> <a href="https://download.csdn.net/download/fuyanhuangyan/9850565" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>