STM32串口控制舵机与LCD显示:从通信协议到PWM映射实战

发布时间:2026/9/8 8:23:21
STM32串口控制舵机与LCD显示:从通信协议到PWM映射实战 简介面向单片机与嵌入式初学者的舵机控制工程示例聚焦通过串口下发角度值驱动舵机旋转并在LCD上同步显示当前角度与PWM设定值覆盖UART通信、PWM脉宽调制、LCD1602显示驱动等核心知识点适合作为课程设计或51单片机进阶练手的参考模板。压缩包共28个文件大小约178KB内含C/H源码、Keil工程文件、Hex烧录文件和Proteus仿真图DSN可供直接编译仿真或烧录到实物验证目录结构清晰便于按模块对照学习。目前已有1368人学习下载。项目采用C语言编写串口解析、舵机角度映射和显示刷新逻辑均较精简借助main.c、uart.h、1602.h等文件读者能清晰理解从接收角度指令到输出对应PWM占空比、再驱动LCD显示的完整链路对正在学习51单片机、想要掌握舵机伺服控制或串口人机交互的开发者有较强的实战参考价值。 做过串口控制舵机项目的人应该都有这种感觉单个模块跑通都不难可真要把“串口收角度值、解析、输出PWM、让舵机转、再刷新LCD显示”这一整条链路打通细节远比想象中多。我前阵子刚好完整做了一遍这套方案——PC端串口助手发一个0到180的角度值STM32收到后控制SG90舵机转到对应位置同时LCD1602上实时显示当前角度和PWM设定值。这个项目适合刚学完串口、定时器PWM和LCD驱动的入门朋友作为整合练习也适合比赛前快速搭一个舵机控制原型。这篇文章会把我的方案选型、协议设计、参数计算、完整代码思路和排坑过程全部梳理出来。1. 项目需求拆解与整体设计思路1.1 三个核心模块各自要解决什么问题表面上这个项目只有三个动作串口发数据、舵机转角度、LCD显示。但拆开看每个模块都有必须严肃对待的技术点。串口侧的核心问题是“怎么定义一个不会出错的通信协议”。如果只是简单地把角度值当成一个字节发过去比如直接发0x5A表示90度会遇到调试不直观、数据边界不清、误码无法识别这几个问题。用串口助手的场景下人眼想看到的应该是可读的文本指令比如“$POS:90”这样既方便手工测试也方便后续用Python或者Qt写上位机时扩展指令集。舵机侧的核心问题是“角度怎么精确映射到PWM脉宽”。SG90这类模拟舵机的工作原理是靠一个20ms周期的高电平脉冲宽度来决定转动角度0.5ms脉宽对应0度2.5ms对应180度。如果直接把角度值当成PWM比较值用而不做映射舵机不是转过头就是转不到位。这里必须做一次线性换算而且换算过程要考虑整数运算的精度和边界。LCD侧的核心问题是“刷新策略”。显示内容只有两行但如果在舵机运动的每一帧都去刷新LCDI2C总线的读写延迟会拖慢主循环极端情况下甚至导致串口接收丢数据。实际做法应该是只在角度值发生变化时才刷新而不是进一次循环刷一次。这三个问题对应了三条独立的技术链路串口协议栈、PWM定时器通道、I2C显示驱动。项目整体就是把它们串成一个闭环串口收数据-校验解析-更新目标角度-重算PWM比较值-刷新LCD。1.2 方案选型为什么是STM32 SG90 I2C接口LCD主控我选了STM32F103C8T6也就是大家常说的“蓝丸”核心板。选它的理由很简单72MHz主频足够同时处理串口中断和定时器PWM标准库资料极度丰富引脚资源对这个项目来说绰绰有余。更重要的是USART1、TIM2、I2C1这三个外设分布在PA9/PA10、PA0/PA1、PB6/PB7这几组引脚上互不冲突不需要做引脚重映射接线非常省事。舵机选SG90而不是更大扭矩的MG995理由是入门项目不需要大负载SG90的控制方式更典型——标准的50Hz、0.5ms到2.5ms脉宽很适合用来验证PWM计算逻辑。LCD选了带PCF8574转接板的I2C接口1602屏而不是传统那种16根引脚的并口屏幕。原因有两个第一I2C版本只需接4根线VCC、GND、SDA、SCL排线全用杜邦线就能解决第二PCF8574本质上是一个I2C转8位并行I/O的芯片弄懂它的驱动逻辑以后驱动OLED、扩展IO都有帮助学一次受益多次。2. 关键原理与参数计算串口协议、PWM映射、LCD显示2.1 串口通信协议先定好规矩再写代码我见过很多人做串口控制时直接在主循环里if(USART_GetFlagStatus(USART1, USART_FLAG_RXNE) ! RESET)读一个字节然后立刻转成角度这样不是不能跑但扩展性和鲁棒性都很差。比如上位机一旦发来一个超范围的数字或者数据中间夹了个换行程序就可能把错误数据当成有效指令。更稳妥的做法是定义一个轻量的行协议以$作为帧起始符指令名和参数之间用:分隔以\n作为结束符。这个项目的完整指令格式是这样的$POS:90\n设计这个格式有几个实际考虑。第一$和\n做边界数据粘包、半包都能靠状态机处理不用依赖串口的中断时序第二文本格式在串口助手里直接敲击即可测试不需要计算十六进制第三以后想扩展指令比如加一个$SPEED:50来控制转速只需要在状态机的指令解析里加一个分支就行协议骨架不用动。串口接收侧我用一个简单的状态机实现核心逻辑是typedef enum { WAIT_START, WAIT_COLON, WAIT_DATA, WAIT_END } RX_STATE; volatile RX_STATE rx_state WAIT_START; volatile char rx_buf[16]; volatile uint8_t rx_len 0; void USART1_IRQHandler(void) { uint8_t ch USART_ReceiveData(USART1); switch (rx_state) { case WAIT_START: if (ch $) { rx_state WAIT_COLON; rx_len 0; } break; case WAIT_COLON: if (ch :) { rx_state WAIT_DATA; } else if (ch $) { // 重复帧头重新同步 } else { rx_state WAIT_START; // 格式错误重新等 } break; case WAIT_DATA: if (ch \n) { rx_buf[rx_len] \0; rx_state WAIT_START; parse_and_execute(rx_buf); // 在这里解析并设置舵机 } else if (rx_len 15) { rx_buf[rx_len] ch; } else { rx_state WAIT_START; // 数据超长丢弃 } break; default: rx_state WAIT_START; break; } }注意代码里两个细节一是状态机在WAIT_COLON状态下遇到$会重新同步这是为了防止上位机从中间开始发数据导致一直接错二是rx_len上限设为了15防止恶意数据冲垮缓冲区。解析部分用atoi()把字符串转成整数再做范围检查只接受0到180的合法角度。非法数据直接丢弃不会干扰舵机当前状态。2.2 舵机角度到PWM占空比的线性映射SG90舵机内部其实是一个带电位器反馈的直流电机它根据输入的高电平脉宽和目标位置的电压比较结果决定电机正转、反转还是停转。也就是说舵机真正看的不是频率而是脉宽。但为什么常规控制都用50Hz周期因为SG90的数据手册明确写了控制信号周期为20ms高电平脉宽0.5ms到2.5ms。如果频率太高比如用200Hz脉宽即使相同舵机内部的模拟电路也可能因充放电不及时而出现抖动或者响应迟钝。STM32F103的PWM生成用的是定时器比较输出。我把TIM2的时钟配置成1MHz预分频7172MHz/721MHz这样每个计数单位正好是1us设置ARR为19999得到20ms的周期。角度Angle对应的比较值计算公式是脉宽(ms) 0.5 (Angle / 180) * 2.0 CCR 脉宽(ms) * 1000 CCR 500 Angle * 2000 / 180代入几个关键点验证一下0度时CCR500对应0.5ms90度时CCR1500对应1.5ms180度时CCR2500对应2.5ms。完全符合SG90的规格。写成代码时我建议用整数运算而不是浮点因为嵌入式环境下浮点运算慢且可能引入IEEE754误差整数表达式(uint32_t)angle * 2000u / 180u最大中间值只有360000uint32_t完全装得下不会溢出void servo_set_angle(uint8_t angle) { uint32_t ccr; if (angle 180) angle 180; ccr 500u (uint32_t)angle * 2000u / 180u; TIM_SetCompare1(TIM2, (uint16_t)ccr); current_angle angle; current_ccr ccr; }每次设定角度时把角度值和CCR值都保存到全局变量里LCD显示就直接用这两个变量不用现算减少模块间耦合。2.3 LCD显示方案I2C接口的1602为什么更适合入门LCD1602原始接口是16引脚的并行接口需要RS、RW、E三个控制信号加8位数据线总共最少要占11个GPIO。这对于正在学基础外设的新手来说光是接线就容易出错。带PCF8574转接板的I2C版本把11个引脚压缩成了SDA、SCL两根线对串口和PWM占用的引脚几乎没有冲突。PCF8574的工作原理不算复杂它就是一颗I2C转8位并行输出的芯片。写LCD时需要把要发送的8位数据拆成高4位和低4位两个半字节分别配合E引脚脉冲写入LCD的内部寄存器也就是“4线模式”。这块逻辑市面上所有标准库和HAL库的LCD1602_I2C驱动都封装好了我实际用下来重点需要确认两件事一是I2C地址常见模块地址是0x27但也有0x3F的变体接上之后先用I2C扫描程序确认一下别上来就默认0x27二是LCD的背光和对比度电位器新模块到手屏幕通电能亮但没有任何字符八成不是程序问题先拿小螺丝刀调一下对比度电位器。显示逻辑这里我定义了一个lcd_show_state()函数专门负责把角度和PWM值格式化输出void lcd_show_state(uint8_t angle, uint32_t ccr) { char line1[17]; char line2[17]; float duty (float)ccr / 200.0f; // ccr/20000*100即占空比百分比 snprintf(line1, sizeof(line1), Angle:%3d Deg, angle); snprintf(line2, sizeof(line2), PWM:%4d %4.1f%% , ccr, duty); lcd_gotoxy(0, 0); lcd_puts(line1); lcd_gotoxy(0, 1); lcd_puts(line2); }占空比的计算我是根据CCR值反推的CCR500时脉宽0.5ms占空比0.5/202.5%换算成百分比就是2.5%对应500/200。这样一行显示PWM计数一行显示百分比调试时可以对照串口助手的输出确认逻辑对不对。3. 完整实操接线、代码实现与联调3.1 硬件接线清单与共地细节整套系统用到的东西不多但接线有几个必须注意的点。完整清单如下设备引脚对应开发板引脚备注SG90舵机信号线橙/黄PA0TIM2_CH1PWM输出SG90舵机电源线红外部5V正极不要直接吃USB口SG90舵机地线棕公共GND必须与MCU共地LCD1602 I2C模块SDAPB7I2C1数据线LCD1602 I2C模块SCLPB6I2C1时钟线LCD1602 I2C模块VCC/GND5V/GND背光供电CH340模块TXPA10USART1_RX交叉连接CH340模块RXPA9USART1_TX可选用于回显接线里最容易出问题的是舵机供电。SG90空载电流大概在100到200mA堵转时瞬间电流能到600mA以上。如果直接用开发板USB口的5V去给舵机供电电压一旦被拉低STM32就会复位表现就是串口指令刚发过去板子就重启了。我的做法是单独用一块小面包板电源模块或者一节3.7V锂电池加升压板给舵机供5V同时把电源模块的地和STM32的地焊在一起。记住一点所有模块的GND必须连在一起否则信号线没有参考电平串口和PWM都会出现莫名其妙的抽风。3.2 串口接收状态机与角度解析代码移植的时候我先把标准库的基础初始化跑通依次是RCC_Configuration开时钟、GPIO_Configuration配置模式、USART1_Init配置波特率、TIM2_PWM_Init配置PWM、LCD_I2C_Init初始化屏幕。初始化顺序本身无所谓但有一个建议LCD初始化放最前面因为屏幕上可以打印一些调试提示比如“Press $POS:xx”之类的后面写代码时方便观察程序跑到哪一步了。串口接收的状态机前面已经写了这里补充解析函数的实现。parse_and_execute在状态机里被调用时拿到的已经是去掉帧头帧尾的纯参数部分比如字符串“90”所以直接转整数void parse_and_execute(char *data) { int angle atoi(data); if (angle 0 angle 180) { servo_set_angle((uint8_t)angle); } // 超出范围就忽略不做任何动作 }这里有个容易被忽略的细节atoi对非法字符串的错误处理不太友好比如收到“12a”会返回12收到“abc”会返回0。如果对安全性有要求应该改成自己写一个数字解析函数逐字符判断是否为0到9的数字。但这个项目场景自己的上位机自己控制atoi加范围检查已经够了。3.3 舵机驱动与LCD刷新的代码实现PWM初始化这段我直接贴标准库的配置注释里把每个参数为什么这么算写清楚void TIM2_PWM_Init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tim; TIM_OCInitTypeDef oc; // 1. PA0复用为TIM2_CH1输出 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); gpio.GPIO_Pin GPIO_Pin_0; gpio.GPIO_Mode GPIO_Mode_AF_PP; // 复用推挽输出 gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); // 2. 定时器时基72MHz/(711)1MHz即1us计数一次 tim.TIM_Prescaler 71; tim.TIM_CounterMode TIM_CounterMode_Up; tim.TIM_Period 19999; // 20ms周期 tim.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, tim); // 3. PWM模式1初始比较值1500即舵机停在90度 oc.TIM_OCMode TIM_OCMode_PWM1; oc.TIM_OutputState TIM_OutputState_Enable; oc.TIM_Pulse 1500; oc.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM2, oc); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); // 4. 使能定时器让PWM立即输出 TIM_Cmd(TIM2, ENABLE); }主循环的逻辑很简单没有阻塞操作只是持续监控全局变量的变化并刷新LCDint main(void) { SystemInit(); USART1_Init(115200); TIM2_PWM_Init(); LCD1602_I2C_Init(); servo_set_angle(90); // 上电先回到中间位置 lcd_show_state(current_angle, current_ccr); while (1) { // 如果角度发生变化刷新LCD if (display_angle ! current_angle) { display_angle current_angle; lcd_show_state(current_angle, current_ccr); } } }这里用display_angle做了个脏标记缓存避免每轮循环都刷LCD。I2C LCD的刷新速度大约每帧几毫秒在72MHz主频下不算慢但如果在串口高速连续发指令的场景下每帧都刷LCD还是会造成中断响应延迟脏标记是最简单的优化手段。4. 调试实录四个最容易踩的坑及解决方案4.1 串口收不到数据或乱码这个项目里串口调试我遇到最频繁的现象是串口助手里发了$POS:90\n舵机纹丝不动。排查的顺序依次是第一看CH340在设备管理器里是否被识别没识别就是驱动问题重新装CH340驱动第二把串口助手的波特率改成和代码一致的1152008N1这是老生常谈第三检查TX/RX是否交叉连接CH340的TX必须接STM32的RX接成同向就收不到第四用示波器或者逻辑分析仪测PA10引脚的波形确认有没有数据进来。如果波形有但MCU没反应检查USART1的GPIO配置是否为浮空输入我见过有人把RX配成推挽输出的导致串口直接被锁死。乱码的情况则多半是晶振和波特率不匹配。标准库默认的SystemInit会把时钟配到72MHz但如果你的板子焊的是8MHz晶振而代码里配成了其他倍频波特率就会漂移。解决方法是确认板子晶振频率再检查stm32f10x.h里的HSE_VALUE是否为8000000。4.2 舵机抖动、角度不准、转不到最大角度舵机抖动常见的原因是PWM信号不稳定。理论上STM32的PWM输出非常精准但如果你把舵机信号线接到了普通GPIO并用软件延时去翻转电平那脉宽抖动几十微秒很正常舵机自然会抖。正确做法是使用定时器硬件PWM因为硬件比较输出不会占用CPU任何中断都不会影响脉宽精度。角度不准则要区分两种情况。第一种是0度位置没对准舵机臂装上去的时候没有在初始角度固定好这个机械误差只能重新安装校准。第二种是映射公式边界不对比如舵机是270度规格但代码按180度映射那自然中间角度都会偏。建议拿到舵机后先用一个简易程序手动设置CCR500、1500、2500观察舵机的实际位置是否对应0、90、180度确认舵机规格后再套公式。转不到最大角度还有一个容易被忽视的原因舵机机械限位。全新的SG90标称是180度但不同厂家生产的实际限位可能只有170度左右强行发180度指令舵机内部齿轮会憋住发出嗡嗡声时间长了会烧电机。所以代码里我做了个安全上限实际输出最大只到175度对应的CCR值给机械结构留一点余量。4.3 LCD黑屏、花屏、字符错位LCD1602 I2C模块最常见的“故障”其实是新模块背面的对比度电位器没有调好。屏幕在默认对比度下可能完全看不到字符但背光亮着。先拿螺丝刀微调电位器到屏幕出现方块再测试代码这是最优先检查的。I2C地址不对也会导致花屏或者无响应。PCF8574模块地址由板上A0、A1、A2三个引脚的电平决定常规模块默认地址是0x27但有些厂家做成0x3F。最稳妥的办法是先用一个I2C扫描程序把I2C总线上的所有设备地址扫出来再改驱动里的地址宏。字符错位一般不是硬件问题而是4线模式初始化时序不对。LCD1602进入4线模式需要连续三次向LCD发送0x30然后切换到4线指令这个顺序不能乱。市面上的I2C转接板驱动库基本都封装好了但如果你从网上随便抄了一个不完整的初始化函数就可能出现隔一个字符显示一个字符的怪问题。我常用的那套标准初始化函数来自树莓派的lcd library移植过来几乎零成本强烈不建议自己从零写初始化时序。4.4 舵机一转单片机就复位这是整个项目里最典型的“低级但致命”的问题。现象是串口发角度后舵机刚开始转开发板突然重启或者LCD闪烁。原因前面说了SG90瞬态电流太大直接把USB口的5V拉低到阈值以下STM32欠压复位。解决思路不是换更大功率的USB口而是把舵机供电和MCU供电隔离。我用了一个独立5V电源模块给舵机供电MCU继续用USB口供电只在GND处连接。另外在舵机电源两端并联一个470uF电解电容和0.1uF瓷片电容能有效吸收瞬态电流波动。如果你手头有示波器可以看一下舵机启动瞬间电源轨的跌落幅度就会明白为什么手册里总是强调舵机要大电容。4.5 常见问题速查表现象可能原因排查顺序串口无任何反应CH340驱动缺失 / TXRX接反设备管理器确认驱动重新交叉接线串口乱码波特率不一致 / 晶振频率不匹配检查代码波特率核对HSE_VALUE舵机不动信号线接错 / PWM没初始化测PA0波形确认TIM2使能舵机抖动软件PWM导致脉宽不稳 / 供电不足换硬件PWM独立电源加电容角度偏大或偏小舵机规格不符 / 映射公式边界问题实测CCR500/1500/2500的位置LCD无显示对比度电位器未调先调对比度再查I2C地址LCD字符错位初始化时序问题替换标准4线初始化函数舵机转动时MCU复位舵机瞬时电流拉低电压独立供电GND共地加470uF电容5. 从这套方案里还能延伸出什么做完这个项目之后我最大的体会是串口、PWM、LCD这三样东西单独拿出来都是单片机的“基本功”但组合在一起就是一个完整的闭环控制系统的雏形。掌握了这套架构后续往两个方向扩展都非常顺手。一个方向是多舵机联动。把TIM2的四个通道都用上或者再开TIM3每路PWM对应一个舵机协议从$POS:90\n扩展成$POS:90,45,120\n状态机里把解析部分换成strtok按逗号分割字符串就能实现。飞特总线舵机、仿生手臂这些更复杂的舵机控制项目也是同样的道理——底层协议变了但“串口指令-解析-执行-反馈显示”的骨架是一致的。另一个方向是角度闭环。现在这个项目的舵机是开环控制——STM32发PWM信号舵机内部自行完成位置反馈MCU并不知道舵机实际到了哪里。如果你在舵机轴上装一个电位器作为角度传感器把传感器反馈值实时显示在LCD上再把串口收到的目标角度和反馈角度对比做PID调节这就是一个完整的闭环伺服系统的基础。最后再分享一个调试技巧串口协议里我故意保留了PC向MCU发送回显的功能MCU每次成功执行$POS指令后会把$ACK:90\n发回给串口助手。表面上这只是一个握手反馈实际排查问题时帮助非常大——舵机不动时看有没有ACK就知道是串口链路断了还是PWM环节出问题能把故障定位时间缩短一半。做嵌入式的朋友都应该养成这个习惯每一级指令执行成功后都往调试口吐一个状态码它比任何调试器都直观。本文还有配套的精品资源点击获取