news 2026/10/5 12:55:08

STM32嵌入式C++实战:ILI9341读ID、CAN总线排查、定时器捕获与超声波测距

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32嵌入式C++实战:ILI9341读ID、CAN总线排查、定时器捕获与超声波测距

最近一直在折腾一块 STM32F103 核心板,从裸机点亮 LED 到用 C++ 封装外设驱动,前前后后写了五篇“嵌入式C++编程之旅”的流水账。昨天夜里在工位上对着屏幕发呆,把之前挖的坑和欠的账挨个捋了一遍,发现欠的活儿比干的活儿还多:屏的 ID 读出来是 A1A1 还没查清楚,CAN 总线在实验室里一插上就正常、一到现场就连不上,定时器捕获测频率只写了中断没写换算公式,还有一堆“等以后有时间再补”的小项目躺着。正好今天状态不错,决定把这些“还差活滴”逐个收拾掉,顺便把过程记录成这篇第六篇。

这篇内容适合正在学 STM32、想从 C 往 C++ 过渡、或者玩嵌入式有一阵子但总被外设细节卡住的朋友。我会把几个高频踩坑点——ILI9341 读 ID 异常、CAN 通信间歇性掉线、定时器捕获测频率的原理与封装、超声波测距的接入——全部拆开讲,最后再把它们攒成一个有点意思的小项目。每个模块都尽量给到可直接抄的代码和排查思路。

1. 前五篇挖的坑,盘点一下还差哪些活儿

写系列文章有个麻烦,就是第一篇说“下期讲串口”,第二篇说“下期讲 SPI”,结果五篇下来,口头的承诺攒了一堆。我先把这个系列目前的欠账拉个清单,也算是本文的“工作项”。

1.1 从串口屏到传感器,欠账其实都是宝藏

前五篇大致覆盖了工程搭建、GPIO 与时钟、串口收发(阻塞和中断两种)、I2C 读传感器、以及初步的 SPI 驱动 LCD。看起来挺全面,但实际上每一个部分都只做到“能跑”的程度,离“好用”还差不少。比如:

  • SPI 驱动 ILI9341 的时候,读 ID 那一步返回的不是 9341 而是 A1A1。当时一句“可能是时序问题,先不管它”就跳过去了。
  • 定时器章节只讲了 PWM 输出,输入捕获测频率只在那篇的结尾提了一嘴。
  • CAN 通信写过一个 demo,但两个板子在家里测试正常,拿到朋友工作室就连不上,原因没细究。
  • 超声波测距模块 HC-SR04 买回来一直吃灰,因为它需要一个完整的回波计时逻辑,涉及定时器、外部中断、甚至还要处理中断嵌套。

这些坑单看是小事,但如果你目标是做嵌入式架构师、或者想把底层驱动写得像点样子,这些“差一点就懂”的知识点反而最值钱。开发板点灯谁都会,能把一个异常 ID 挖到底、能把一个“上次还能连”的 CAN 总线修好,才是长进。

1.2 这一篇要动工的清单

所以这一篇我给自己列的“活儿”如下:

  1. 深挖 ILI9341 读 ID 读成 A1A1 的根因,把 SPI 读时序彻底搞明白。
  2. 写一个可复用的定时器输入捕获测频率类,用 C++ 封装掉硬件的寄存器操作。
  3. 复盘 CAN 通信“突然连不上”的排查全过程,给出我实测有效的定位顺序。
  4. 把 HC-SR04 超声波模块接入工程,并跟定时器捕获功能整合。
  5. 最后把这些东西组合成一个小项目:STM32 鱼缸管家——用超声波测水位、LCD 显示状态、CAN 给另一个节点上报数据。

每一项都不算难,但组合起来就是一套真正的嵌入式小系统。下面从最难缠的 ILI9341 开始。

2. 老冤家 ILI9341:读 ID 读出来 0xA1A1,到底是谁的锅

ILI9341 是市面上最常见的一款 TFT-LCD 驱动芯片,很多 2.4 寸、2.8 寸屏幕模块都号称用的是它。读 ID 的常规做法是调用读命令 0xD3,按理应该返回 0x93、0x41 这两个字节。但在我的板子上,读回来的固定是 0xA1、0xA1。

2.1 先复盘一下正确的读 ID 时序

ILI9341 读 ID 的命令是 0xD3,它属于“带参数读回”的命令。意思是:主机先发送命令字节 0xD3,然后芯片会在接下来的时钟里依次输出若干字节,其中包括命令本身的回显、保留字节、制造商 ID、驱动版本号等。不同厂商兼容屏可能略有差异,但标准 ILI9341 的输出结构是:

字节序号含义典型值
第1字节命令回显(读0xD3时返回0xD3或0x00)0x00 或 0xD3
第2字节参数1(保留)0x00
第3字节参数2(保留)0x00
第4字节制造商ID高字节0x93
第5字节制造商ID低字节0x41

所以如果你用逻辑分析仪抓数据,正确流程是:发送 0xD3 -> 连续读 5 个字节 -> 取最后两个字节拼接成 0x9341。

我当初的代码犯了一个典型错误:发送 0xD3 后只读了 3 个字节,然后把第 4 个字节取出来当 ID。结果那个字节正好是 0xA1——不是参数 2 就是命令回显的扰动值。更迷惑的是,为什么两次读都是 0xA1A1?这是因为 SPI 时钟空闲电平如果配错,MISO 线上读到的是上拉/下拉的固定电平,表现为稳定 0xFF 或 0xA1 或 0x00。这种“稳定的错误数据”比“一坨乱码”更坑,因为它看起来像“有规律的结果”,会诱导你去检查芯片手册而不是检查时序配置。

提示:读 ID 读成固定重复值(比如 0xA1A1、0xFFFF),90% 是读时序问题,不是芯片坏了。尤其要检查 SPI 的 CPOL/CPHA 是否匹配 ILI9341 的 MODE 3。

2.2 修复读 ID 的代码:状态机 + 足够长的读取序列

我用的开发环境是 VSCode + CMake + arm-none-eabi-gcc + 自制 Makefile,这套组合确实比 Keil 舒服不少,至少代码补全和 git diff 都正常。读 ID 的代码我重写成下面这样,关键点是“发送命令后立刻拉高 CS 一次,再重新拉低,然后读回”的间隔操作不能少:

#include "spi_driver.hpp" #include "gpio_driver.hpp" namespace lcd_ili9341 { using namespace drivers; // 自定义的外设驱动命名空间 uint32_t readId() { // 硬件 SPI 初始化,模式 3:CPOL=1, CPHA=1 Spi spi(SPI1, SpiMode::MODE3, 18_MHz); Gpio cs(GpioPort::A, GpioPin::PIN4, GpioMode::OUTPUT_PUSH_PULL); cs.setHigh(); // 发送 0xD3 命令 cs.setLow(); spi.transfer(0xD3); cs.setHigh(); // 小延时,等待芯片内部锁存命令 delayMicroseconds(10); // 重新拉低 CS,开始读回 cs.setLow(); uint8_t buf[5]; for (int i = 0; i < 5; i++) { buf[i] = spi.transfer(0x00); // 读操作也发 0x00 时钟 } cs.setHigh(); // 拼接最后两个字节作为 ID uint32_t id = (static_cast<uint32_t>(buf[3]) << 8) | buf[4]; if (id == 0x9341 || id == 0x9488 || id == 0x5225) { // 兼容 ILI9341、ILI9488、HX8357 等常见屏 return id; } return 0; } }

代码里有几个细节值得注意:

  • 为什么发命令后要拉高 CS 再拉低?因为 ILI9341 的命令执行和读取之间需要一个片选脉冲来隔开,否则部分兼容屏会把命令字节的回显和读回数据混在一起。
  • 为什么循环里发送的是 0x00 而不是 0xFF?SPI 是全双工,读一个字节的同时必须发送一个字节以产生时钟。大多数情况下发送 0x00 或者 0xFF 都行,但有些对时序敏感的芯片,主机发 0xFF 会产生多余的上升沿干扰内部状态,发 0x00 最稳。
  • 为什么只读 5 个字节?标准流程确实只需要 5 个字节。如果你接的是兼容屏,可能读 5 个字节后 ID 还会继续输出更多,但只要取最后两个字节,基本都能对齐。

跑完上面的代码,我的屏幕读出来 0x9488——所以它根本不是 ILI9341,而是一块 ILI9488 兼容屏。当初卖家标“ILI9341 兼容”,实际驱动是 9488。这就是为什么“读 ID 非常重要”的原因:你花几块钱买的屏幕,驱动可能跟手册写的完全不一样,后续初始化的时序、像素格式配置全部要跟着实际驱动走。

2.3 从 A1A1 事件学到的排查思维

这次排错给我的最大启发是:嵌入式调试不要被“稳定的错误输出”迷惑。不稳定输出说明线路/时序抖动,反而是更容易定位的问题;稳定输出往往意味着某个逻辑点在系统性地偏移。A1A1 就是“读取字节数少了两个 → 整体数据左移两位 → 字节 3 和字节 4 变成了 0xA1 和 0xA1”的典型案例。假如当时拍了逻辑分析仪,一眼就能看出少了两个时钟周期,根本不用纠结芯片是真是假。

顺带一提,排查这类问题最好用的三板斧:一是 SPI 的 MISO 线务必用逻辑分析仪看原始波形,别只看读出值;二是如果手头只有示波器,至少确认 SCK 空闲电平是高还是低;三是怀疑兼容屏时,直接试读多组命令(0xD3、0x04、0xDA/0xDB 等),拿返回值交叉对比。ILITEK 系驱动对读命令的支持其实挺统一,多试几组你就知道芯片是哪家的了。

3. 定时器输入捕获测频率:从原理到 C++ 模板封装

定时器这一篇我当时只写了 PWM 输出,没写输入捕获。这也算是一个很典型的欠账——因为输入捕获和 PWM 正好是定时器模块里最核心的两大功能。一个用来“发出精确的时序”,一个用来“测量外界的时序”。很多同学的误区在于觉得输入捕获就是配个中断、读一下寄存器,但实际上要测出准确的频率,必须搞明白“预分频”“重装载值”“捕获极性”三者之间的关系。

3.1 测量频率的本质:数两次上升沿之间有几个计数单位

定时器输入捕获的基本原理是:信号从某个引脚进入定时器的捕获通道,当检测到设定的边沿(上升沿或下降沿)时,定时器会把当前的计数器值“捕获”到影子寄存器里,同时触发中断。如果我们连续记录两次上升沿的计数值,先记为 X1 和 X2,那么:

  • 周期(秒) = (X2 - X1) × 计数频率的倒数
  • 频率(Hz) = 1 / 周期

计数频率 = 定时器时钟频率 / (预分频系数 + 1)。

举个例子,STM32F103 的 APB1 时钟 36MHz,定时器时钟是 APB1 的 2 倍,即 72MHz。如果我们设置预分频为 71,则计数频率 = 72MHz / (71+1) = 1MHz,也就是说每个计数单位代表 1 微秒。这时如果测到两次上升沿间隔 1000 个计数单位,则信号周期 1ms,频率 1kHz。

记住这个关键换算:预分频决定了测频的分辨率,重装载值决定了最大量程。预分频越大,每个计数单位代表的时间越长,能测的周期越长,但分辨率越差。

3.2 用 C++ 写一个可复用的 FrequencyMeter 类

我在 C++ 封装里喜欢用模板传参数,因为这样可以最大限度地让编译器在编译期算出定时器相关的寄存器地址和中断号,运行时几乎零开销。这个思路跟传统 C 语言的“结构体回调”风格很不一样,但更符合现代嵌入式 C++ 的习惯。

#pragma once #include "stm32f1xx.h" #include <cstdint> namespace drivers { template <typename TimerRegs, std::uint32_t PscDividor> class FrequencyMeter { public: static void init(std::uint32_t prescaler, std::uint32_t period) { TimerRegs::CR1 = 0; TimerRegs::PSC = prescaler; // 分频,决定计数单位时间 TimerRegs::ARR = period; // 自动重装载,决定最大量程 TimerRegs::CCMR1 = 0x01; // 输入捕获模式,映射到 TI1 TimerRegs::CCER = 0x01; // 上升沿触发捕获 TimerRegs::DIER |= TIM_DIER_CC1IE; // 使能捕获中断 TimerRegs::CR1 |= TIM_CR1_CEN; // 使能定时器 // 这里是重点:开启捕获中断前,先把上一次的捕获值清掉 (void)TimerRegs::SR; } static float getFrequencyHz() { std::uint32_t cap1 = m_lastCap1; std::uint32_t cap2 = m_lastCap2; if (cap2 <= cap1) { cap2 += (TimerRegs::ARR + 1); // 处理溢出回绕 } std::uint32_t ticks = cap2 - cap1; float seconds = static_cast<float>(ticks) * (static_cast<float>(PscDividor + 1) / 72e6f); return 1.0f / seconds; } static void irqHandler() { if (TimerRegs::SR & TIM_SR_CC1IF) { m_lastCap1 = m_lastCap2; m_lastCap2 = TimerRegs::CCR1; TimerRegs::SR = ~TIM_SR_CC1IF; } } private: static inline volatile std::uint32_t m_lastCap1 = 0; static inline volatile std::uint32_t m_lastCap2 = 0; }; }

这段代码有几点值得展开讲讲,因为它们就是嵌入式 C++ 和普通桌面 C++ 最不一样的地方:

  • static inline volatile std::uint32_t m_lastCap1这里必须用volatile,因为中断里会改写它,主循环里读它,编译器如果优化过头,很可能把两次读取合并成一次,导致频率值完全不对。这是新手最容易栽的坑,我见过不少人 Debug 版正常 Release 版乱跳,就是丢了这个关键字。
  • 把寄存器地址用模板参数传进去而不是用全局变量,好处是同一个类可以实例化出 TIM2、TIM3 多个测频器,互不干扰,而且没有虚函数,没有间接跳转。配合 constexpr,运行时开销跟手写汇编一样低。
  • 中断服务函数里先读SR再清标志,这个顺序不能反。某些 STM32 系列要求在清标志前先读取中断捕获值,否则可能丢中断。

3.3 实测:1kHz 方波测量结果

我把这个类实例化到 TIM2 通道 1(PA0 引脚),输入端接一个 1kHz 的 PWM 信号,预分频设为 71,重装载值设为 0xFFFF。实测频率显示 999.87Hz 到 1000.32Hz 之间跳动,这个误差主要是信号本身的抖动,不是定时器分频不准。如果是做工业级测频,可以用两次捕获值做多次平均,或者开 DMA 批量搬运捕获结果,这个以后有机会再展开。

到这里,“定时器捕获测频率”这个欠账算是补上了。但我在测试的时候,顺手做了一个很蠢的接法,把信号线跟隔壁 CAN 总线的线绞在一起了,结果引出了下一节那桩破事——CAN 通信突然连不上。

4. CAN 通信“突然连不上”的排查实录

热搜里有人问“stm32 can通信突然连不上”,我太有共鸣了。因为 CAN 的问题是最难描述的:不是一开始就连不上,而是跑着跑着、或者换个环境就掉线。我自己遇到过的情况是:家里两张板子用两根杜邦线互联,收发一切正常;拿到朋友工作室,插上他的板子,我这边的节点怎么都进不了正常模式。

4.1 最容易被忽略的凶手:终端电阻和接地电位

先把结论摆出来:那次联调失败,最终根因是接了两个节点但两端都没有接 120 欧终端电阻,同时还引入了共地问题。CAN 的物理层是差分信号,CAN_H 和 CAN_L 之间必须通过终端电阻来保证信号完整性。标准规定总线的两端各接一个 120Ω 电阻。如果只有两个节点,那么每个节点各接一个 120Ω,正好匹配 60Ω 的差分阻抗。如果不接:

  • 信号会有反射,尤其在波特率较高时(500kbps 以上),接收节点的采样点会读到错误的显性/隐性电平。
  • 如果只有一个节点接了 120Ω,总线阻抗是 120Ω 而不是 60Ω,信号幅度偏大或偏小,某些收发器(如 TJA1050)进入总线关闭状态,表现为哪怕配置正确也进不了正常模式。

另一个问题是共地。CAN 看似只有两根线,但收发器内部的比较器需要一个相对参考电位。两个节点如果不共地,CAN_H 和 CAN_L 的共模电压会漂移,当漂移超过收发器输入范围(通常 ±12V,但实际建议越小越好)就会误判。解决办法是两块板子的 GND 一定要连在一起,不要只靠下载器自带的地线。

4.2 检查顺序,一步一步来

我整理了一套现场排查 CAN 的固定流程,每次照着走基本都能定位:

  1. 先看波形:示波器探头接 CAN_H 和 CAN_L 之间,让任意一个节点持续发数据。正常时能看到幅值约 2V 的差分方波;如果看不到,回到配置层。
  2. 量电阻:断电状态下,用万用表量总线两端的电阻。两个 120Ω 并联应该显示约 60Ω;如果显示 120Ω,说明一端没接终端;如果显示 0Ω,说明短路。
  3. 查波特率:STM32 的 CAN 波特率计算公式为CAN_CLK / (BRP × (TSEG1 + TSEG2 + 1))。比如 APB1 36MHz,目标 500kbps,如果设置BRP=4, TSEG1=13, TSEG2=2,那么 36M / (4 × (13+2+1)) = 36M / 64 = 562.5kbps,这就明显不对。建议用官方工具或者现成的波特率计算器核对。
  4. 查过滤器:CAN 过滤器配置成屏蔽模式时尤其容易坑。如果掩码设置成全 0(不过滤任何 ID),那么所有报文都收;如果全 1(必须完全匹配),那么一个 ID 对不上就全收不到。我之前遇到过 FIFO0 和 FIFO1 的映射写反了导致报文进了没读的 FIFO,表面上看就是“连不上”。
  5. 查错误状态:STM32 的 CAN 外设带 CAN_ESR 寄存器,可以读取 TEC、REC、LEC。如果 TEC 持续上涨到 128 而 REC 也在涨,说明发送方和接收方波特率不一致,或者总线存在错误帧风暴。这个寄存器的 DEBUG 价值极高。

我把本次排障的参数整理成了下面的参考表,方便对照:

检查项正常值异常情况可能原因
总线电阻约 60Ω120Ω 或 0Ω缺终端电阻 / 短路
CAN_H 与 GND 直流2.5V 左右0V 或 5V收发器损坏 / 供电异常
CAN_L 与 GND 直流2.5V 左右0V 或 5V收发器损坏 / 供电异常
差分信号幅值约 2V1V 以下总线负载过重 / 终端电阻错误
TEC / RECTEC < 16, REC < 16TEC/REC 上涨波特率不匹配 / 错误帧
ESR.LEC0b000非零位错误 / 填充错误 / 确认错误

4.3 那一次最终怎么解决的

我那次排查的转折点是拿示波器点差分信号,发现波形完全正常,但 STM32 一直卡在 CAN 初始化检查CAN_InitStatus。后来用 C++ 写了一个调试辅助函数,把CAN_ESR->TEC和CAN_ESR->REC的值直接通过串口打印出来,看到 TEC 在持续上升。这证明节点本身配置没问题,物理层也没问题,问题在总线负载上——朋友工作室那根线束太长,线缆电阻太大,两个节点中间还串了一个手动开关,接触电阻把信号衰减到接收阈值以下。换成短线直连后,通信立刻恢复正常。

注意:CAN 总线虽然抗干扰能力强,但线缆长度、线径、端子接触电阻这些都会影响信号质量。现场环境里“突然连不上”很多时候是机械接触问题,别上来就重刷固件。

这件事之后,我把 CAN 的配置都封装成了一个小工具类,把波特率、过滤器、中断回调都集中管理,不再像以前那样 GPIO 配一处、CAN 配一处、中断配一处,找问题的时候到处翻。

5. HC-SR04 超声波测距:两路定时器协作的经典场景

热搜里“stm32超声波测距”热度一直很高,市面上教程也确实多,但绝大多数是纯 C 的延时阻塞版。既然我们在聊嵌入式 C++,我就用它来做一次“定时器输入捕获 + 定时器输出比较”结合的例子。

5.1 测距原理和接法

HC-SR04 模块有四个引脚:VCC、Trig、Echo、GND。测距流程分三步:

  1. 给 Trig 引脚一个大于 10μs 的高电平脉冲,模块内部会自动发出 8 个 40kHz 的超声波脉冲。
  2. 模块收到回波后,会在 Echo 引脚上输出一个高电平脉冲。
  3. 高电平持续的时间等于「声波从发射到被测物再返回的总时间」。距离 = 持续时间 × 声速(约 340m/s) / 2。

因为要同时控制 Trig 的输出脉冲,又要精确测量 Echo 高电平的持续时间,我用 TIM3 通道 1 做输出比较,用 TIM2 通道 2 做输入捕获。前者负责精准控制 Trig 高电平时间,后者负责捕捉 Echo 上升沿和下降沿的时间戳。

接线表很简单:Trig 接 PA6(复用为 TIM3_CH1 输出比较),Echo 接 PA1(复用为 TIM2_CH2 输入捕获)。

5.2 实现细节

下面是核心代码,我尽量让每一行都有注释和理由:

#include "frequency_meter.hpp" #include "ultrasonic.hpp" #include "lcd_ili9341.hpp" namespace aquarium { class Ultrasonic { public: static void init() { // 配置 Trig:PA6,推挽输出,初始低电平 Gpio trig(GpioPort::A, GpioPin::PIN6, GpioMode::OUTPUT_PUSH_PULL); trig.setLow(); // 配置 Echo 为输入捕获,预分频 71,计数单位 1us // 这里选择 TIM2,因为 TIM2 是 32 位定时器,测 100ms 级别的高电平不容易溢出 FrequencyMeter<Tim2Bridge, 71>::init(71, 0xFFFFFFFF); // 配置捕获中断里记录两个边沿的时间戳 Timer2::CCER = TIM_CCER_CC2E; // 默认捕获上升沿,等第一次触发后切换为下降沿 } static float measureDistanceCm() { // 触发 Trig 高电平 20us(稍微大于最小要求的 10us) trig.setHigh(); delayMicroseconds(20); trig.setLow(); // 清空上次的捕获状态 Timer2::SR = 0; // 等待上升沿 while ((Timer2::SR & TIM_SR_CC2IF) == 0) { /* 等待捕获中断 */ } uint32_t riseTime = Timer2::CCR2; // 切换为下降沿捕获 Timer2::CCER &= ~TIM_CCER_CC2E; Timer2::CCER |= TIM_CCER_CC2E | TIM_CCER_CC2P; // 配置下降沿 // 等待下降沿 while ((Timer2::SR & TIM_SR_CC2IF) == 0) { /* 等待捕获中断 */ } uint32_t fallTime = Timer2::CCR2; // 恢复为上升沿捕获 Timer2::CCER &= ~TIM_CCER_CC2E; Timer2::CCER |= TIM_CCER_CC2E; uint32_t durationUs = fallTime - riseTime; // 距离(厘米)= 时间(us) * 0.034 / 2,这里直接把公式化简成: float distanceCm = (durationUs * 0.034f) / 2.0f; return distanceCm; } private: static inline Gpio trig{GpioPort::A, GpioPin::PIN6, GpioMode::OUTPUT_PUSH_PULL}; }; }

这段代码用的是“轮询等待捕获标志”的方式,好处是逻辑直观,不会出现中断并发问题。实际产品里我更建议用中断 + 状态机,因为超声波模块的响应时间最长可达 20ms 以上,阻塞式等待会卡死主循环。如果你要在一个项目里既刷 LCD 又发 CAN,那肯定不能阻塞。这里我只是为了演示捕获原理,才把阻塞写法放在显眼位置。

5.3 实测与滤波

实测将 HC-SR04 对准 30cm 外的纸箱,返回值在 29.8cm ~ 31.2cm 之间跳。对于这种噪声,我做了一个很轻量的滑动窗口滤波,取最近 5 次测量值排序后取中位数,效果立竿见影,波动幅度从 ±1.2cm 压到 ±0.3cm。关于滤波算法,嵌入式领域不需要整太复杂,排序 5 个数的插入排序足够用,别一上来就上卡尔曼。

经验提示:超声波对软性物体的反射会很差(比如棉被、毛绒玩具),实测时选硬质平面,不然容易收到二次回波或完全收不到回波导致超时。

截止到这里,本文已经把这些“欠账”逐个填平了。但单独验证每个模块只是第一步,真正有意思的事情是把它们串起来,形成一个像样的系统。

6. 攒一个小项目:STM32 鱼缸管家

这个项目的灵感来源于热搜里的“stm32鱼缸”,我把它扩展成了集水位监测、数据显示、CAN 上报为一体的一个迷你系统。它不复杂,但足以覆盖前面讲的所有知识点:定时器输入捕获测脉冲、LCD 驱动、CAN 收发、模块化 C++ 设计。

6.1 系统结构和硬件连线

鱼缸管家的功能定义很简单:

  • 用超声波模块贴在鱼缸顶部往下测,计算出水面高度。
  • 水位值实时显示在 ILI9488(之前误认成 ILI9341)屏幕上。
  • 如果水面低于阈值,通过 CAN 总线给“喂食节点”发一条告警信息。
  • 另外一个霍尔流速计接在循环水泵出水管上,用定时器捕获测出脉冲频率,换算成流速,也显示到屏幕上。

硬件连线如下:

模块引脚STM32 引脚说明
HC-SR04 TrigTrigPA6定时器输出比较
HC-SR04 EchoEchoPA1定时器输入捕获
ILI9488 LCDSCK/MOSI/CSPB13/PB15/PB12SPI1
CAN 收发器TX/RXPA12/PA11CAN1
霍尔流速计脉冲输出PA0TIM2_CH1 测频

6.2 C++ 模块划分

项目目录结构如下:

aquarium_manager/ ├── src/ │ ├── main.cpp │ ├── lcd_ili9488.cpp │ ├── ultrasonic.cpp │ ├── can_bus.cpp │ └── flow_meter.cpp ├── include/ │ ├── drivers/ │ │ ├── spi_driver.hpp │ │ ├── gpio_driver.hpp │ │ └── timer_driver.hpp │ └── app/ │ ├── aquarium_state.hpp │ └── sensor_fusion.hpp └── CMakeLists.txt

这里我特别想聊一下sensor_fusion.hpp的设计思路。它做的事情很简单:把超声波测量的水位值和霍尔流速计测的流量值,做一个低竞争、高内聚的聚合,统一输出一个AquariumReport结构体,然后由上层决定是显示还是上报。为什么要单独拆一层?因为嵌入式开发里最容易发生的灾难就是“主循环里堆了一坨 if-else,每个外设的状态互相穿插,后期根本没法维护”。我习惯的做法是:

  • 底层驱动类只负责“跟硬件寄存器对话”。
  • 应用层的sensor_fusion只负责“把不同传感器的数据融合成一个业务对象”。
  • 主循环只负责调度和分发。

这样当你想从“LCD 显示水位”改成“LCD 显示水位 + OLED 显示流速”时,影响范围被限制在显示层,传感器采集逻辑完全不用动。

6.3 主循环:调度式而非阻塞式

主循环的写法如下:

int main() { SystemInit(); lcd.init(); can.init(500_kbps); ultrasonic.init(); flowMeter.init(); while (true) { // 每 500ms 采集一次水位 if (timeoutCheck(500)) { float waterLevel = ultrasonic.measureDistanceCm(); sensorFusion.updateWaterLevel(waterLevel); } // 每 200ms 采集一次流速 if (timeoutCheck(200)) { float flowRate = flowMeter.getFrequencyHz() / kPulsePerLiter; sensorFusion.updateFlowRate(flowRate); } // 每 1000ms 刷新一次 LCD if (timeoutCheck(1000)) { lcd.showReport(sensorFusion.getReport()); } // 水位过低时通过 CAN 发出告警 AquariumReport report = sensorFusion.getReport(); if (report.waterLevel < kLowWaterLevel && !report.alarmSent) { can.sendMessage(0x180, reinterpret_cast<uint8_t*>(&report), sizeof(report)); sensorFusion.markAlarmSent(); } } }

这种“时间片轮询 + 数据融合”的架构,比裸奔的 while + delay 强得多。它不会因为某一次的超声波测量超时(比如测到软性物体)就卡住整个 LCD 刷新,因为你不会阻塞地在同一个循环里等待传感器结果。即使某个传感器模块彻底坏了,系统也只是显示旧值,不会死机。

6.4 试运行和调试心得

这个项目我实际跑了两天,真实发现的问题反而比预想的多,这里简单列出几条:

  • 把超声波模块直接贴在鱼缸玻璃外侧测水位,结果是废的——玻璃会反射声波,模块发出的波大部分在玻璃表面就弹回来了。最终我把探头改成从鱼缸顶部向下照射水面,才拿到可信数据。
  • 霍尔流速计在满管水时脉冲频率很稳定,但水压低时脉冲会抖,这时如果还用 200ms 窗口测频率,波动极大。解决办法是把测量窗口拉到 1s,并且每次只测完整周期数,换算时取整。
  • LCD 刷新千万别整屏刷,否则 SPI 速率 18MHz 也要卡顿。我只刷变化区域的行,实测流畅很多。

提示:做“鱼缸管家”这类整合项目,最大的价值不在硬件多贵,而在于逼你去处理传感器脏数据、多外设协同、异常降级这些真实工程问题。这是任何开发板上“点灯实验”给不了的训练。

7. 写在最后:还差活滴,恰恰是嵌入式最好的状态

这一篇从 ILI9341 的 A1A1 排查开始,到定时器捕获测频率、CAN 掉线排障、超声波模块接入,最后攒了一个鱼缸管家小系统。每个模块单独拿出来都不算高深,但要是都精通,已经离“嵌入式架构师”的目标近了一大步。热搜里有人问“嵌入式学习路线”怎么走,我的体会是:别贪多求快,一个外设至少踩过三个坑才叫会用。像 CAN 这种不碰现场不知道会出问题的总线,光看手册是永远学不会排障的。

最后再分享一个小技巧:在 STM32 工程里,强制开-Wall -Wextra -Werror,把警告当错误处理。很多人跑来问为什么程序运行一段时间会跑飞,我看代码发现一堆未使用变量和的隐式类型转换警告都挂着。这些警告不是凭空来的,它们往往预示着你会读错寄存器位宽、写错数组下标。把编译器当成第一道防线,能把后面调硬件的时间省出一大截。

我自己的开发板也还在桌上躺着,USB 设备枚举那部分还差着“活滴”——下回估计就要写怎么让 STM32 变成一个小键盘或者自定义 HID 设备了。嵌入式这条路就是这样,永远有活,永远学不完,但也因此才一直有意思。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 12:47:56

OpenCV+轻量CNN实现稳定人脸表情识别

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 12:47:56

NNLM神经网络语言模型详解:从统计语言模型到词向量的核心技术

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 12:46:23

STM32F732IE 与 MRAM 工业存储方案:SPI 驱动、DMA 优化与掉电保护

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 12:45:03

OpenRig铝型材DIY赛车座舱:选型与组装避坑指南

很多人第一次看到OpenRig这个项目时&#xff0c;第一反应都是&#xff1a;就这几根铝合金方管搭起来的架子&#xff0c;真的能扛得住方向盘那一下一下的拉扯吗&#xff1f;先说结论&#xff0c;能&#xff0c;而且远比你们想象中稳。OpenRig本质上是一个开放式铝型材DIY模拟赛车…

作者头像 李华
网站建设 2026/10/5 12:43:44

paperclip 实战:Node.js 与 React 模式下的 AI Agent 编排与避坑指南

1. 从“paperclip”这个名字说起&#xff1a;它到底想解决什么问题第一次看到paperclip这个项目名&#xff0c;我脑子里蹦出来的画面是 Word 里那个弯弯曲曲的回形针助手——那个被无数人吐槽、却又在关键时刻能帮你把格式调对的“小助手”。这个命名其实挺妙的&#xff1a;它暗…

作者头像 李华
网站建设 2026/10/5 12:43:44

Python手写最速下降、牛顿法与BFGS优化算法,高维二次函数对比

1. 为什么还要手写这三种最优化算法先抛一个问题&#xff1a;scipy.optimize.minimize一行代码就能跑完的活&#xff0c;为什么还要自己用 Python 手写最速下降法、牛顿法、拟牛顿法&#xff1f;我最初也这么想&#xff0c;直到有一次我在处理一个带正则项的高维二次目标函数时…

作者头像 李华