最近我一直在玩一个挺有意思的项目——用一块 STM32 单片机,做一只摆在显示器旁边、会眨眼睛、会摇头、还会根据你的状态给出反应的“智能桌面宠物”。说实话,市面上带屏幕的桌面小摆件很多,但你真想让它跟你有“交流感”,自己用单片机从零写一版,是性价比最高、也最能学到东西的路子。
这个项目用到的核心硬件是 STM32 主控,我选的是最常见的 STM32F103C8T6,外围配了一块小尺寸的 OLED/TFT 屏、两个舵机(一个管摇头、一个管点头)、一个温湿度传感器、一个超声波测距头、再加一个离线语音合成模块。整体做下来,撕开外壳之后其实是一堆跳线和模块堆出来的“裸体小怪兽”,但只要逻辑对,它表现出来的“性格”比很多量产产品都鲜活。
这篇博文我会从需求拆解、硬件选型、软件状态机、常见坑这几个角度完整讲一遍设计思路和实现过程,所有代码和接线都是我踩过坑后调通的,你可以直接照着抄。
1. 项目概述与整体设计思路
1.1 做之前先想清楚:什么样的宠物才算“智能”
我先说结论:智能桌面宠物不是把功能堆得多,而是把反馈做得“像活物”。它的核心价值不是跑分、不是算法,而是陪伴感。你人靠过来它看你一眼,摸它一下它会开心地晃头,温度太高它会表现出热得吐舌头;到了夜里它还能自动睡过去,呼噜呼吸灯一闪一闪。这种“拟人化反馈”才配叫宠物,不然就是个温度计加屏幕。
所以从产品角度,整个项目按“感知—决策—表现”分成三层,朴素理解就是“输入—脑子—输出”:
- 感知层:触摸传感器、超声波测距、温湿度传感器、光敏电阻,甚至还可以加按键。它们负责回答“当前环境发生了什么”。
- 决策层:STM32 内部的状态机程序。它负责把感知层的输入翻译成情绪,比如“手靠近 -> 好奇”、“被摸 -> 开心”、“环境太亮 -> 眯眼”。
- 表现层:屏幕显示表情、舵机点头摇头、语音模块说话、LED 灯效变化。
这一个分层思路比直接上手写一堆驱动代码重要得多,因为它决定了你后续加功能的顺序和模块划分。我第一批做出来的时候只实现了表情和触摸反馈,感知层用得很少,但程序写出来整个模块之间的接口已经是清晰的,后面加温湿度、超声波,基本就是“插一个新的输入源 + 建立一个触发规则”,老代码几乎不用动。
1.2 为什么选 STM32 而不是 51、ESP32 或树莓派
这是最开始最容易纠结的地方,我直接说结论和相关对比数据:
| 方案 | 优点 | 缺点 | 适不适合桌面宠物 |
|---|---|---|---|
| 51单片机 | 便宜、上手快、资料极多(江科大51笔记是很多人的入门课) | Flash/RAM 极小、外设少、动画刷新吃力,多任务靠中断硬扛 | 不太适合,屏幕刷新和舵机控制同时扛会明显吃力 |
| STM32(F1系列) | 72MHz、外设丰富(TIM、SPI、I2C、USART、ADC)、有 DMA、资源均衡、成本低 | 配置复杂些,需要熟悉 CubeMX 或寄存器,对新手有学习曲线 | 非常适合,性能和体积刚好匹配 |
| ESP32 | 自带 WiFi/蓝牙、双核、生态强 | 价格稍贵、裸机实时性不如 ST 可控,做表情动画时序控制容易受协议栈干扰 | 如果是做联网产品可考虑,但纯桌面宠物有点大材小用 |
| 树莓派 | 能跑高级视觉/语音模型 | 功耗大、启动慢、外围复杂、成本高 | 不适合,杀鸡用牛刀 |
选 STM32 实际上选的是一个确定性。桌面宠物动作和表情的时序是通过定时器中断和 PWM 精确控制的,STM32 的通用定时器数量和精度在这类小项目里是最舒服的。另外 STM32F103C8T6 这个芯片在国内平台上的价格已经压得很低了,而且中文资料、开源例程多到看不完,遇到问题基本没有搜不到的。
1.3 状态机设计:从“流水账程序”到“有性格的程序”
桌面宠物的灵魂是“情绪状态”,情绪状态我建议用一个枚举状态机来管理,而不是在 while(1) 里一堆 if 到处乱飞。我的项目里定义了这么几个状态:
| 状态 | 触发条件 | 表现动作 |
|---|---|---|
| IDLE 待机 | 上电初始化完成 | 眨眼动画、慢速呼吸灯、偶尔抬头观察 |
| HOVER 好奇 | 超声波测距检测到有人靠近(距离小于30cm) | 睁大眼睛盯着你、头转向你、伴随“嗯?”音效 |
| TOUCH 开心 | 触摸传感器按下触发 | 眯眼笑、点头、身体LED变暖色 |
| HOT 热到 / MEMO 提示 | 温湿度传感器检测到温度偏高 | 吐舌头帧、风扇启动 |
| SLEEP 休眠 | 光敏电阻检测到光线很暗 或 长时间无交互 | 屏幕熄灭、进入 STOP 模式、RTC 定时唤醒 |
这套状态机最大的好处就是:你把“宠物性格”固化成了几个状态迁移条件,后续想改性格,只需要改触发阈值或状态迁移表,不用重写整个业务逻辑。比如我想让它变得更粘人,就把靠近触发距离从 30cm 调到 50cm,这比改一万行 if 快得多。具体状态跳转代码我在后面第 3 章会贴一个直接可用的框架。
2. 硬件选型与环境搭建要点
2.1 主控芯片:C8T6 还是 ZET6,选型要覆盖多少余量
STM32F103C8T6 的片上资源:主频最高 72MHz、64KB Flash、20KB SRAM、7 个定时器(3个通用+1个高级+2个基本+1个系统滴答)、3 个 USART、2 个 SPI、2 个 I2C、10 个 12 位 ADC 通道、1 个 CAN、1 个 USB 从机。光是这几个外设,桌面宠物四个字完全吃得下。
但如果你不仅仅是做“表情+舵机”,你还要同时跑 OLED 动画、语音串口播报、温湿度采集、超声波测距、触摸中断,那么 C8T6 的 64KB Flash 会开始紧张。我实际写下来,HAL 库工程 + 标准库 + 完整状态机 + 表情字库,最后编译出来固件大概在 45KB 左右,还留有余量,但如果你要加 FreeRTOS 和小型 FatFS 做日志记录,就建议直接上 F103ZET6(512KB Flash)或者 F407 系列了。
| 芯片型号 | Flash/RAM | 适合场景 |
|---|---|---|
| STM32F103C8T6 | 64KB / 20KB | 纯桌面宠物、表情+传感+舵机,入门神板 |
| STM32F103ZET6 | 512KB / 64KB | 后续加 FreeRTOS、加 OTA Bootloader、加更丰富动画 |
| STM32F407VET6 | 512KB / 128KB | 需要硬件 FPU 跑更复杂的音频或视觉采样时再上 |
选型建议:先买 C8T6 做通整个原型,等发现 Flash 不够了再迁移到 ZET6,这两个芯片引脚定义和初始化逻辑基本一致,迁移成本低。
2.2 显示设备:OLED 还是 TFT,取模方式和刷新策略
桌面宠物的脸是核心交互面,所以我建议至少使用 1.3 寸以上的屏幕,而不是 0.96 寸。我一开始用的 0.96 寸 128x64 OLED,表情能显示,但总觉得“脸”太小气,后来换了 1.8 寸 TFT(分辨率 128x160,驱动 ST7735),效果立刻就不一样了。
| 屏幕 | 驱动芯片 | 通信接口 | 优点 | 坑 |
|---|---|---|---|---|
| 0.96 寸 OLED | SSD1306 | I2C/SPI | 功耗低、字库取模简单、视角好 | 刷新慢、尺寸小 |
| 1.8 寸 TFT | ST7735 | SPI | 彩色、160x120、便宜 | 需要背光控制、SPI时序要求严 |
| 2.4 寸 TFT | ILI9341 | SPI/并口 | 大、色彩好、适合大头动画 | 刷新占CPU多、功耗高 |
TFT 刷表情最核心的技巧是局部刷新:不要把整个屏幕全帧重画,而是只更新眼睛、嘴、腮红这几个区域。 STM32 刷 128x160 全屏 SPI 大约要 20ms 左右,如果每一帧都全刷,动画帧率上不去,CPU 也会被拖累;把画布拆成“表情元素区域”后,每帧只重画约四分之一区域,帧率立马上来。
取模工具我用的是 PCtoLCD2002 和 Image2Lcd,注意选“纵向取模、字节倒序”的方式,然后直接把数组塞到一个 frame buffer 里,统一刷新。
2.3 舵机与动作执行机构:SG90 的驱动细节
宠物会动才能称为“宠物”,否则就是电子相框。我做了两个自由度:头部左右摆(用一个 SG90 舵机)和身体点头(用一个 MG90S 金属舵机,因为点头负载比想象中略大)。动作机构选择上,桌面宠物不需要高扭矩,MG90S 和 SG90 已经足够,太大体积反而夹在桌面上不自然。
SG90 舵机用 50Hz(20ms 周期)PWM 信号控制,脉宽 0.5ms~2.5ms 对应角度 0~180 度。抬头的关键在输入捕捉,测脉宽和生成脉宽都依赖定时器。直接用 STM32 定时器的 PWM 输出通道,配置好周期为 20ms、脉宽寄存器值 250~1250(以 1MHz 计数频率为例)就能驱动。
一般换算公式如下:
角度角度映射到占空比: pulse_ms = 0.5 + (angle / 180.0) * 2.0 arr = 20000 - 1 // 20ms 周期 ccr = (pulse_ms * 1000) - 1 // 以微秒为单位的比较值这里有个注意事项:STM32 定时器计数频率要用内部 PCLK 分频后正确配置,我实际用的时候发现如果计数频率算错 10 倍,舵机就像抽风一样抖,这个排查了一下午。
舵机供电不能直接从 STM32 的 3.3V 引脚拉,因为启动瞬间、以及快速来回转的时候峰值电流能到几百毫安,3.3V 的 LDO 会直接被拖垮,导致单片机复位。我是用 5V 稳压模块单独给舵机供电,并且共地;如果需要更优雅一点的方案,可以直接用一块 3.7V 锂电池 + 升压板,舵机由升压后的 5V 供电,单片机由 3.3V LDO 供电。
2.4 传感器与语音模块:感知层的四件套
我用到的传感器做了 4 件套,分别为距离感应、触摸感应、环境光感应、温度感应:
- 超声波测距(HC-SR04):负责“有人靠近”检测。它测距范围 2cm~400cm,桌面场景够用。接在普通 GPIO 上,用定时器输入捕获来测高电平时间,距离 = 高电平时间(us) * 0.034 / 2,单位厘米。
- 触摸感应(TTP223 电容触摸模块):负责“被摸”检测。TTP223 的数字输出直接接 MCU EXTI 外部中断引脚,非常可靠,比自己做电容触摸方便太多。
- 光敏电阻模块(LM393 比较器版输出数字量/模拟量均可):我接在 ADC 上读取模拟值,用来判断“白天还是晚上”,晚上就自动进入睡眠模式。
- 温湿度传感器(DHT11):用来读取桌面上环境的温湿度,温度高于预设值就触发“热”状态并配合风扇。
这里最直观的体验是:传感器越多,状态机的有趣程度越高。但我建议不要一上来把所有传感器全焊上去,先做触摸+测距两种输入,程序跑通后再往上加。
语音模块我用了 SYN6288 离线语音合成模块,通过串口发送 GB2312 编码的汉字文本,模块就能发出中文语音。它的播报速度比较慢,适合短句,比如“你好呀”、“要睡觉啦”这几句,做成长句会有很强的机械感。串口波特率我用的是 9600,与之对应的注意点请直接看第 4 章串口乱码排查。
2.5 开发环境搭建:Keil + CubeMX 与芯片包的那些坑
如果你刚开始玩 STM32,建议从标准套路开始:
- 安装 Keil MDK(注意:Keil5 可以同时支持 C51 和 STM32,但需要分别安装对应芯片包)
- 安装 STM32CubeMX 生成初始化代码
- 在 Keil 里安装 STM32F1 芯片支持包,点击 Pack Installer 搜索 STM32F1xx,安装到本地
- 用 ST-Link 下载器连接 MCU 的 SWD 接口
Keil5 兼容 C51 和 STM32 安装有个经典坑:很多人先装了 C51 版本的 Keil,再升级到 MDK 版,却发现新建工程时没有 STM32 选项。原因多半是只装了 C51 设备包,没有装 STM32 的 DFP(Device Family Pack)。解决办法是在 Pack Installer 里搜索 “Keil.STM32F1xx_DFP” 安装即可。
我在第一次玩的时候还踩过“无法识别 USB 设备”的坑——用 ST-Link 连接到电脑时提示无法识别。多数情况是 ST-Link 固件版本太旧或驱动没装好,重新安装 ST-Link 官方驱动,或者用 STM32 ST-LINK Utility 升级一下 ST-Link 固件就解决了。
ST-Link Utility 我自己现在不仅是下载器,还拿来干一件事:读备份固件。某些最小系统板和废旧板子里可能烧过别的东西,先用 Utility 把整个 Flash 读出来备份到 .hex 文件,万一调坏了还能恢复,这个习惯养成了能救命。
VSCode 用户如果想绕开 Keil,可以用 EIDE 插件搭配 ARM GCC 工具链,配合 ST-Link 的 OpenOCD 下载。我用过一段时间,体验是:代码提示和 git 集成确实好,但初学阶段 Keil 的工程向导和在线调试会更省心,建议先把 Keil 调试流程跑通再折腾 VSCode。
3. 软件实现与核心环节代码
3.1 初始化流程:时钟树与 GPIO 的配置
STM32 上电第一件事不是点亮屏幕,而是配置时钟树。F103 默认上电使用内部 HSI 8MHz,如果不切换时钟,你的 SysTick 定时、串口波特率、PWM 频率全都按 8MHz 算,外设频率必然不对,表现就是屏幕刷得特别慢、串口全是乱码。我用 CubeMX 配置为外部 8MHz 晶振 HSE 作为 PLL 输入,PLL 倍频到 72MHz。
CubeMX 生成的初始化代码里,SystemClock_Config() 里几个关键参数如下:
RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE; RCC_OscInitStruct.HSEState = RCC_HSE_ON; RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE; RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9; // 8MHz x 9 = 72MHz RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1; RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2; // 36MHz RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1; // 72MHz这个话题非常关键,时刻记住一句话:无论你用什么工具生成工程,时钟树必须心里有数。JiangKeDa(江科大)的 STM32 笔记就特意把时钟树画得很细,值得反复看。我自己的教训是:把外部晶振换过,结果忘了改 CubeMX 里的 HSE Value,导致波特率差了整整一代。
GPIO 上,OLED 屏幕用 SPI1(PA5/PA7/PA3/PA4),舵机 PWM 用 TIM2 的 CH1(PA0)、TIM3 的 CH1(PA6),触摸用 PA1 EXTI,测距触发用 PB0、回波输入用 PB1 且输入捕获接到 TIM4,DHT11 接 PB4,语音模块接 USART2 的 TX/RX(PA2/PA3)。
3.2 表情动画与 UI 实现:从“憨憨两块黑”到“有表情的面孔”
表情绘制我用的是先把整个屏幕当作一块 frame buffer,然后在 RAM 里改像素,最后一次性通过 SPI 搬到显示模块。这样做的好处是:所有状态表情(眨眼的中间帧、吐舌头的嘴型变化)不需要反复调用画线函数,而是直接改 buffer 坐标。
比如 OLED 是单色屏,帧缓冲数组可以直接定义成:
uint8_t frame_buffer[128 * 8]; // 128x64 OLED:8页,每页128字节TFT 是彩色屏,用 RGB565 格式,128x160 的 buffer 就是 128 * 160 * 2 = 40KB,直接超出了 C8T6 的 20KB SRAM。所以 TFT 不能整帧缓冲,只能做局部区域缓冲。我的办法是拆成两个 16x16 的眼睛区域和一个 24x16 的嘴巴区域,每次更新这 3 个局部区块,这样缓冲区只要 4KB 左右。
眼睛眨眼的动画我用“下眼睑”帧处理实现的:正常眼是一整个圆,眨眼帧画成半圆,快速在 2 帧间切换,视觉上就比较自然。展示给别人的效果是,它靠着屏幕边缘打盹时会半眯着眼,非常可爱。
屏幕坐标和触摸屏坐标对应的问题是另外一个方向,后面我在第 5 章扩展时会提,这里先按下不表,记住屏幕坐标永远是从左上角开始。
3.3 舵机动作序列:点头、摇头、慢速环顾
光有随机 PWM 走到固定角度是没灵魂的,我把动作封装成了“动作帧序列”,类似小动画:
typedef struct { uint16_t duration_ms; // 持续时间 int16_t head_yaw; // 头部偏转角度,-45~45 int16_t head_pitch; // 点头角度,-20~30 uint8_t led_brightness; // 代表表情灯亮度 } MotionFrame;然后为按钮触摸、陌生人靠近、开心状态各配置一套动作数组。比如“开心”就是快速点头两次 + 停顿 + 轻轻摇一摇。实现方式是把动作数组放在 Flash 常量区,状态机进入 TOUCH 状态时把指针指向这个数组,在定时器中断里逐帧处理。
舵机平滑控制是个注意点:直接写目标角度,会让舵机像跳变一样快速甩头,太机械。我加了每 10ms 只允许角度变化不超过 1 度的斜坡限速,效果立刻从“遥控玩具”变“有质感的生物”。
3.4 主循环与状态迁移:写一个不会卡死的 while(1)
裸机程序容易写得像流水账,我的结构是:主循环只做状态决策,所有硬件事件都走中断。
// 事件标志,在中断里置位,主循环查询 volatile uint8_t event_flag = 0; #define EVENT_TOUCH (1 << 0) #define EVENT_NEAR (1 << 1) #define EVENT_TIMEOUT (1 << 2) while (1) { // 状态机决定行为 switch (pet_state) { case STATE_IDLE: idle_animation(); // 眨眼呼吸灯,不阻塞 if (event_flag & EVENT_NEAR) { pet_state = STATE_HOVER; event_flag &= ~EVENT_NEAR; } if (event_flag & EVENT_TOUCH) { pet_state = STATE_TOUCH; touch_timeout = 3000; } break; case STATE_TOUCH: happy_animation(); if (HAL_GetTick() - touch_timeout > 3000) pet_state = STATE_IDLE; break; // 其他状态同理 } // 非阻塞的模块刷新 display_flush(); udpate_sensor_fsm(); // 喂看门狗 // IWDG_ReloadCounter(); }核心哲学很简单:无论如何,主循环里绝对不出现 HAL_Delay()。所有的延时都用状态 + 时间戳判断。只要你写一次这种任务式状态机,回头再看“全速跑还剩多少余力”这个问题就会清晰很多:这一套在 72MHz 下 CPU 占用甚至不到 30%,剩余算力完全可以用来做一些更炫的特效。
3.5 传感器采集与数据解析:超声波、DHT11 的具体实现
超声波 HC-SR04:先拉高 TRIG 引脚至少 10us,然后等待 ECHO 引脚上升沿,记录定时器计数,等待下降沿再记录一次,两个计数值的差换算成时间。用定时器输入捕获通道可以精确测量,不用自己死等:
void Ultrasonic_Trigger(void) { HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_SET); delay_us(12); HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_RESET); } // ECHO 上升沿捕获回调里记录 start_cnt,下降沿记录 end_cnt // distance_cm = (end_cnt - start_cnt) * (1.0 / timer_freq_hz) * 340.0 / 2.0 * 100DHT11 是单总线协议,时序敏感,必须在关闭中断的情况下进行位读取。我建议把它放到一个定时器中断的低优先级任务里执行,或者在主循环里读取时先 PRIMASK 关闭中断,防止时序被破坏。DHT11 的数据格式是 5 字节:湿度整数、湿度小数、温度整数、温度小数、校验和,最后校验和等于前四字节相加的低 8 位。
这里还要提醒:DHT11 的数据脚必须接 4.7k 上拉电阻到 VCC,否则大概率读出来全是 0xff。这是新手最容易忽略的一个硬件设计点。
3.6 串口调试与日志:重定向 printf 到 USART
调试桌面宠物时没有串口日志那是寸步难行。我在工程里直接把 printf 重定向到 USART1:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }同时在 CubeMX 里注意把 USART1 波特率设成 115200,与串口助手一致。日志格式我习惯做成 “[状态] 距离=25cm 温度=23.5C 电量=78%” 这种一行一个信息,配合时间戳,能让状态机的迁移过程全程可追溯。
串口还有一个额外用途:作为“远程调试遥控器”。我在 UART1 接收中断里实现了极简命令协议,比如发字符串 “h” 代表手动设置宠物开心,“l” 代表睡觉,这样在调试动作序列时不用老用手去摸传感器。这个协议同时也为后续蓝牙/WiFi 扩展做好了铺垫。
3.7 低功耗模式:不插电也能摆一天
桌面宠物如果一直插着 USB,功耗无所谓,但如果你想让它彻底“桌面化”而不是“充电线化”,低功耗就很有必要。我实现了光照变暗后进入 STOP 模式:关闭外设时钟、进入 STOP,然后 RTC 定时每 5 秒唤醒一次检查环境光,如果还暗就继续睡,直到天亮再苏醒。
Stop 模式下 C8T6 电流大约能降到 20uA 级别,搭配一节 2000mAh 的锂电池,理论待机能撑非常久,不过实际算上传感器静态功耗,满电撑个两三天问题不大。注意进去之前要把 OLED 屏幕的背光和舵机供电全部关闭,否则它们反而成了漏电大头。
4. 常见问题与排查技巧实录
4.1 编译下载阶段:USB 无法识别、芯片包没装好
- “Keil 工程新建后没 STM32 选项”:去 Pack Installer 安装 Keil.STM32F1xx_DFP,或者手动导入本地下载的 .pack 文件。
- “电脑无法识别 USB/串口设备”:排查 ST-Link 驱动是第一步,第二步是确认板子的 Boot0 和 BOOT1 跳线。如果是 CH340 USB-TTL 的板子,检查 CH340 驱动是否正常安装。
- “Flash Download failed - Cortex-M3”:核验器件型号选择是否对应的 F103C8,下载算法选 STM32F10x Med-density Flash 512K(现在主流 IDE 会自动匹配),还有个更隐蔽的原因:GPIO 里把 SWD 的 PA13/PA14 配置成了普通 IO,导致第二次下载时下载器无法建立连接。解决办法是按住板子复位键的同时点击下载,或者先用 ST-Link Utility 将 Flash 整体擦除。
4.2 堆栈问题:C 语言裸机程序有没有堆栈?为什么裸机也会爆栈
这里回答一个很多新手会困惑的问题:单片机裸机程序没有操作系统,那 C 语言里的栈还存在吗?答案:存在。即使不用 RTOS,C 语言函数调用时局部变量、函数返回地址、保存的寄存器都要压到栈里;中断发生时,CPU 也会自动把现场压栈。STM32 启动文件 startup_stm32f103xb.s 里明确设置了:
Stack_Size EQU 0x00000400 Heap_Size EQU 0x00000200Stack_Size 是这个裸机程序的默认栈大小,C8T6 当前默认 1KB。如果你的函数里定义了很大的局部数组,比如 uint8_t buf[1024],嵌套调用再深一点,栈立刻超出,表现就是程序跑着跑着重启,或者在调试时跳进 HardFault_Handler。
我自己的坑是在显示表情的局部变量里放了一个 512 字节的临时缓冲,再加上中断嵌套,把栈挤爆导致随机死机。排查方法:在 HardFault_Handler 里打断点,查看 MSP 指针和栈回溯。建议直接把 startup 文件里 Stack_Size 改成 0x1000(4KB),代价只是 SRAM 少 4KB,但稳定性大幅提升。
关于堆(Heap),裸机工程里 malloc 用得不多,但如果你的驱动里调用了 malloc/free,就需要注意 Heap_Size 要足够大,或者干脆不用 malloc,而是把内存池静态分配做到底。
4.3 显示屏问题:白屏、花屏、刷新慢、文字乱码
- 白屏:检查 OLED/TFT 的 CS、DC、RST 引脚是否都按 CubeMX 配置正确。TFT 的 RST 引脚必须用软件复位时序:拉低至少 10ms,再拉高,很多白屏都是复位脉冲过短。
- 花屏:绝大多数情况是 SPI 模式不匹配。ST7735 需要 SPI Mode 0(CPOL=0,CPHA=0)或 Mode 3(CPOL=1,CPHA=1),如果你在 CubeMX 里选了 Mode 1/2,画面绝对不对。
- 刷新慢:不是代码问题,是通信模式问题。OLED 在 I2C 模式下最快也只能跑 400kHz,SPI 模式轻松上 8MHz,所以要追求动画流畅,我强烈建议用 SPI 接屏幕,别省那两根线。
- 中文乱码:OLED/TFT 显示中文需要字库,取模时要注意编码与输入匹配。语音模块需要 GB2312 编码,而 UTF-8 是默认编码,所以你这个“中文”发过去大概率是乱码。我在 Keil 里把源文件编码设置成 GB2312,或者在代码里用转码函数把 UTF-8 字符串转成 GB2312 字节数组,问题就解决了。
4.4 舵机问题:不转、抖动、复位重启
- 舵机完全不转:先测信号线电压是否正常,确认 PWM 周期是不是 20ms。如果用了通用定时器,注意 APB1 和 APB2 的总线频率不同,同样分频系数下产生的 PWM 频率会差一倍。
- 舵机抖动:多半是供电不足或共地没接好。舵机瞬态电流很大,单独供电并且与 MCU 共地是必须的,否则信号参考地会跳,PWM 电平就会被干扰。
- 舵机一动,单片机就重启:绝世经典坑。原因是舵机电流拉低电源电压,导致 MCU 的 3.3V 电压跌落触发了 BOD 复位。解决方法是给舵机单独供电,或者用一个大容量电解电容(470uF 或更大)并接在舵机电源两端。我实测加电容后重启问题立刻消失。
4.5 串口乱码与 sensordata 异常:时钟树优先级高于一切
串口乱码 90% 是时钟配置问题的表现。比如板载晶振是 8MHz,但 CubeMX 里 HSE 值设成了 25MHz,那么 HAL 计算出来的波特率实际会偏差很大。这个偏差不是“反正能通”的程度,而是完全读不到逻辑电平。同理,测距不准、PWM 周期不对,都要先回时钟树检查。
还有个我踩过的细节:RCC 的 HSE_VALUE 宏定义在 stm32f1xx_hal_conf.h 里,默认值是 8000000,如果你换了外部晶振 12MHz,这里不改,CubeMX 生成的时钟树代码看起来正确,但底层函数计算波特率时会用错频率。所以任何涉及时间的模块异常,第一个想到的就是“晶振频率全工程搜一遍”。
5. 扩展方向与我的个人经验
5.1 从桌面宠物扩展到更多智能硬件
这个项目的框架做好后,可以衍生出很多更强的应用:
- 添加 ESP8266/ESP01 串口 WiFi 模块,让宠物定时访问天气接口,然后把“今天要不要带伞”翻译成表情和语音:下雨就做皱眉脸加“记得带伞哦”,比手机天气推送有温度得多。
- 添加蓝牙模块,配合小程序做一个手机遥控版,触摸坐标从手机屏幕映射到屏幕内容,就涉及“触摸屏坐标对应到界面内容”的坐标换算逻辑:用线性映射把触摸 ADC 或坐标点除以屏幕像素比并做去边界裁剪。
- 用 K210 视觉模块通过 UART/SPI 与 STM32 通讯,识别你的手势,比如挥一挥它就开心,拳头它就害怕,这是一个很好的“AI + MCU”结合的入门项目。
- 加 OTA 功能:利用 STM32 片上 Flash 剩余扇区,做 Bootloader + App 双区结构,STM32 可以通过串口接收新固件并跳转,进阶玩法可以通过 WiFi 模块把新固件拉下来实现无线升级。
- 加空闲 IO 口,配合 PWM 输出控制 LED 灯带、继电器,甚至可以做一个“桌宠自动喂食器”或者“空调伴侣”,直接用文本描述触发规则。
5.2 我做完这个项目的几点直接体会
第一点,模块化设计思维比代码技巧重要得多。我早期写单片机程序全是把所有功能堆在 main.c 里,几千行代码后来自己都理不清。这个项目我一开始就分成 sensor.c、display.c、motion.c、state_machine.c 几个模块,接口定义好,后加功能就是填表填逻辑,效率高很多。
第二点,先做骨架再做血肉。我做桌面宠物第一版的时候,舵机都是用手拧着测试的,屏幕只显示白底黑块,状态迁移也只有 3 个状态。等整个数据流链路从传感器到表现层都通了,才开始认真调表情动画和动作曲线。如果你一开始就去纠结表情好不好看,很可能做了三天你还在画眼睛,别人已经把完整功能跑通了。
第三点,条件允许的话,留一个物理开关和复位键在底部。桌面宠物最大的使用场景是摆桌面上,半夜它也可能触发超声波或触摸,所以一个可以快速重启、快速关机的物理开关非常重要。这不是技术难题,但是产品思维。工业上有句话叫“机器要有能被手抓住的把手”,对桌面宠物而言,底部一个轻触开关就是它的“把手”。
最后分享一个小技巧:如果你想快速验证表情和动作效果,做一个 USB 串口命令表,用电脑的串口助手替代传感器,直接给状态机发指令看表现。这相当于给你的宠物造了一套“情绪遥控器”,调试效率能提升一半不止。尤其是你准备把这个项目拿去做课程设计或毕业设计时,这套串口命令协议本身就是一个很好的自动化测试和演示手段。