这个系列写到第6篇,板子上的基础外设跑得七七八八了。STM32的GPIO能翻灯,串口能打印日志,定时器中断也能按点干活……但真要把这个工程端出去给人看,我心里清楚还差不少“活”。差活不是指某个寄存器没配好,而是整个项目离“能交付”还缺几个功能模块:要能测外界信号频率,要能读一段距离,最好还得有块屏把数据显出来。这篇我就把欠账理了一遍,然后逐个补齐,用的还是嵌入式C++那套老办法:把外设封装成类,把中断和状态机管清楚,把现场调试的坑一五一十记下来。适合正用STM32做小项目、又不想退回C风格写一堆全局变量的朋友参考。
1. 先盘盘手里的牌:系列迭代到第6篇,还差哪几样“活”
1.1 “差活”清单怎么排:不是想到哪补到哪
很多嵌入式项目都是这样:主芯片的外设驱动调通之后,感觉“板子活了”,其实距离一个完整设备还差一截。我的做法是打开两个本子,一个写“能做”,一个写“还差”。前五篇下来,“能做”那页已经写得挺满:GPIO的C++封装、串口环形队列、定时器PWM、按键状态机、Flash参数存储,都是些零件级的活。“还差”那页则是从整机角度看才会冒出来的功能,我挑出最关键的三个:测频率、测距离、显示数据。
排序的标准不是“有趣”,而是风险。LCD看起来难,但它的SPI时序是整个项目里最成熟的部分,只要芯片型号确认,驱动方案网上大把;超声波模块的Echo时序对响应时间敏感,做不好会把主循环卡死;定时器输入捕获则是寄存器细节最密的一块,溢出处理、重映射、中断优先级,稍不注意就白干。技术风险高的先做,做完了再回头处理显示,整个项目的地基才稳。排序之后我在看板软件上打了三个勾,今天要一口气清掉。
1.2 为什么这几件活用C++封装更划算
这几件活都有共同特点:硬件操作多、状态变化多、要对外提供稳定的接口。拿C写也能跑,但全局变量会越来越多。比如测频率需要四个变量记录两次捕获值,超声波需要两个中断标志,屏驱动更是几十个常量。混在一起,一旦现场改需求,你根本不知道哪个变量在哪改。
C++的类封装这时候就体现出价值了。一个FreqMeter对象把定时器句柄、通道号、捕获缓冲全收在私有成员里,外部只关心readHz();一个DistanceSensor对象把状态机收在内部,测距过程中主循环可以继续干别的事。这还带来一个附加好处:每完成一个类,我可以在串口日志里打印“构造完成”,上电自检的框架就顺手搭出来了,后面接LCD、接传感器,走的是同一条路。
2. 补活一:定时器输入捕获测频率,别再用Delay数数
2.1 输入捕获的原理和初始化细节
测频率最朴素的办法是拿for循环数电平翻转次数,再掐秒表,但那个精度只能用来演示,真正要测从几十赫兹到几十千赫兹的信号,必须用定时器的输入捕获。原理不复杂:GPIO引脚复用成定时器通道,信号上升沿来了,定时器计数器当前值会被硬件自动锁存到捕获寄存器,CPU完全不用参与,捕捉的时序抖动只受硬件传输延迟影响,比任何软件轮询都干净。
以STM32F103为例,我用TIM2的通道1,引脚PA0。初始化时有个容易漏的点:PA0默认功能是复用推挽输出,但作为捕获输入,CRL寄存器要配成浮空输入或上拉输入,很多人没改这一项,引脚功能还停留在默认状态,捕获自然不触发。正确顺序是先开GPIOA时钟和AFIO时钟,把PA0配置为输入,再把TIM2的CC1通道映射到TI1,设置上升沿捕获,打开捕获中断,最后启动定时器。
分频系数在这里要想清楚。系统时钟72MHz,TIM2挂在APB1总线上。我把PSC设为71,这样计数器计数频率是1MHz,一个计数脉冲对应1微秒,逻辑上最好换算。但计数器本身只有16位,65535微秒就溢出一次,测非常低的频率会出问题,这个我在2.2节用代码处理。
2.2 FreqMeter类封装:把中断和溢出算清楚
我的FreqMeter类长这样,基于HAL库写,核心是捕获回调里的逻辑:
class FreqMeter { public: FreqMeter(TIM_HandleTypeDef* htim, uint32_t channel) : htim_(htim), channel_(channel), first_capture_(0), second_capture_(0), capture_index_(0), freq_hz_(0.0f) {} void start() { HAL_TIM_IC_Start_IT(htim_, channel_); } float readHz() const { return freq_hz_; } void onCapture(uint32_t ccr_val) { if (capture_index_ == 0) { first_capture_ = ccr_val; capture_index_ = 1; } else { second_capture_ = ccr_val; uint32_t period = (second_capture_ >= first_capture_) ? (second_capture_ - first_capture_) : (0xFFFFU - first_capture_ + second_capture_); if (period != 0) { freq_hz_ = 1000000.0f / static_cast<float>(period); } capture_index_ = 0; } } private: TIM_HandleTypeDef* htim_; uint32_t channel_; volatile uint32_t first_capture_; volatile uint32_t second_capture_; volatile uint8_t capture_index_; volatile float freq_hz_; };HAL库的中断回调是HAL_TIM_IC_CaptureCallback,而且是全局函数。要让这个C++类收到事件,我习惯用一个静态实例指针做转发,或者在中断里直接调用全局的单例。这个设计并不优雅,但在嵌入式C++工程里很常见,因为它避免了动态分配,也避免了中断向量表和C++成员函数之间的ABI问题。
溢出问题必须处理。16位计数器在1MHz计数频率下65.535毫秒溢出一次,如果被测信号低于约15Hz,两个上升沿之间计数器已经回绕了好几圈,只靠两次CCR差值算出来的频率就是错的。我的处理办法是在定时器更新中断里维护一个overflow_count,捕获中断发生时把当前溢出次数一起记录下来,计算时把溢出补偿算进去。如果项目里测的就是几十赫兹以上的信号,可以不做这层补偿;但既然是要端出去用的活,建议还是加上,代码量不大,却能避免低频段数据乱跳。
2.3 实测数据与误差分析
接上信号源实测,200Hz到50kHz这一段,读出的频率和信号源设定值基本一致,误差在0.1%以内,足够大多数现场使用。误差主要来自两个地方:一是捕获中断的响应延迟,虽然硬件锁存了计数器值,但中断处理里读取CCR的时刻和真正捕获时刻之间还有几个CPU周期的延迟,测高频时这个延迟占比会变大;二是输入信号的边沿抖动,如果前端没有施密特触发器整形,带毛刺的信号会让捕获值来回跳。
实际调试时我还遇到一个量程切换问题。同一套分频参数,测高频很准,但一旦信号掉到几十赫兹,上位机显示的数字会跳得非常厉害。后来我想明白了:几十赫兹的信号周期是几十毫秒,计数器早就溢出多圈,溢出补偿那段逻辑跑得再对,中断里读取溢出计数时也可能和捕获事件发生次序错开。解决方法是把测频逻辑分成两档:高频用CCR差值,低频用定时器溢出计数除以时间窗,两种算法写在一个类里,用readHz()统一返回,上层完全无感。
3. 补活二:HC-SR04超声波接入,Echo脉冲要交给定时器和中断
3.1 测距时序分析:10us触发和Echo高电平
超声波模块的硬件原理不复杂,HC-SR04引脚只有四个:VCC、GND、TRIG、Echo。给TRIG发一个大于10微秒的高电平,模块内部会自动发送一串40kHz的超声波脉冲,然后Echo引脚输出高电平,高电平持续时间等于声波往返的总时间。距离等于时间乘以声速再除以2,因为声波走了一个来回。
最容易出错的地方是把Echo高电平时间理解成“可以阻塞等待”。新手很容易写这样一段:TRIG拉高延时再拉低,然后while循环死等Echo变高,再等它变低,中间量个时间。这段代码在单独测模块时没问题,一旦工程里还有LCD刷新、按键扫描、串口打印,测距的几十毫秒内所有任务全部卡死,界面直接掉帧,按键按了没反应。我踩过一次,当时还以为是任务切换出问题,后来才发现是阻塞等Echo把整个主循环堵住了。
3.2 不要阻塞等回波:用状态机和定时器
正确思路是把Echo引脚的上升沿和下降沿都配成外部中断,测距流程变成一个异步状态机。触发之后状态切到“等待上升沿”,上升沿中断来了,启动定时器计时,状态切到“等待下降沿”;下降沿中断到了,停掉定时器,读计数器值,算出高电平持续时间,状态切回空闲。整个过程中主循环可以自由跑显示、跑按键、跑通信,只是测到的新数据可能在中断里,也可能在一个侧缓冲区里,主循环需要时直接取用。
定时器计时范围也要算好。HC-SR04量程常见是2厘米到400厘米,对应往返时间大约是117微秒到23.5毫秒。16位计数器在1MHz计数频率下能计65.5毫秒,量程数量级够用,但要记得把定时器配成单次计时模式,避免下降沿中断还没来,计数器先溢出把数据冲掉。我在第一次接入时没开单次模式,结果Echo高电平时间超过计数器回绕周期,读出来的距离凭空多了一大截,排查了很久才反应过来。
3.3 DistanceSensor类实现与滤波
封装成C++类之后,流程就清楚多了:
enum class MeasureState { Idle, WaitingRising, WaitingFalling }; class DistanceSensor { public: void trigger() { if (state_ == MeasureState::Idle) { HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_SET); // 使用硬件定时器产生10us延时,不要用阻塞delay start_trigger_timer(); state_ = MeasureState::WaitingRising; } } void onEchoRising() { if (state_ == MeasureState::WaitingRising) { __HAL_TIM_SET_COUNTER(echo_tim_, 0); HAL_TIM_Base_Start(echo_tim_); state_ = MeasureState::WaitingFalling; } } void onEchoFalling() { if (state_ == MeasureState::WaitingFalling) { HAL_TIM_Base_Stop(echo_tim_); uint32_t us = __HAL_TIM_GET_COUNTER(echo_tim_); float dist_cm = us / 58.2f; // 声速往返换算 updateFilter(dist_cm); state_ = MeasureState::Idle; } } float getCm() const { return filtered_cm_; } private: MeasureState state_ = MeasureState::Idle; float filtered_cm_ = 0.0f; };代码里us / 58.2f这个系数值得多说一句。声速约340米/秒,换算成微秒和厘米,就是高电平时间每增加58.2微秒,距离增加1厘米。网上有人用343米/秒,算出来56.7微秒对应1厘米,实际空气中声速随温度变化,常温下58.2是比较通用的折中值。真要做精密测量,可以用温度传感器实时算声速,但普通测距场景没必要。
滤波我用了最朴素的中值加平均:连续采集5个点,去掉最大值和最小值,剩下三个取平均。这个算法在MCU上跑,开销很小,却能压制超声波模块常见的野值和抖动。实测下来,面对墙面测30厘米到100厘米,读数稳定在正负0.5厘米以内;遇到模块正对墙角或者倾斜反射面,偶尔会出现一个明显离谱的跳点,靠中值滤波能直接挡掉。
3.4 测量周期和功耗的取舍
测距还有一层设计问题:多久触发一次?我见过一个坑,有人把trigger()放在主循环里不断调用,结果模块还没完成上一次测量,新的触发又来了,状态机直接错乱。正确的节奏是给测量周期设一个闸门,比如200毫秒触发一次,这样每秒最多测5次,既满足显示刷新需求,也不会把模块和超声波总线搞乱。如果做低功耗电池项目,还要注意测出距离超过量程时,Echo高电平的时间会特别长,接收超时时要把状态机强制拉回Idle,否则下一次触发永远进不了门。
4. 补活三:ILI9341读ID读成0xA1A1,一次完整的排障记录
4.1 故障现象和数据含义
LCD这块屏,我用的是SPI接口的ILI9341,代码里第一步通常都是读控制器ID。读出来的ID大厂芯片是0x9341,这几乎是入门共识。但我把代码烧进去,串口打印出来是0xA1A1,而且连续读多次都是同一个值。这明显不对——A1这个字节在9341的数据手册寄存器表里根本不存在。网上搜这个现象的人不少,很多帖子都在问,答案五花八门。这个故障非常典型,值得把排查过程完完整整捋一遍。
先解释一下0xA1A1的含义。读SPI设备的ID,本质上是在MISO线上读回芯片的响应字节。如果MISO线悬空,读回来的通常是0xFF或0x00;如果MISO线上有不确定的上下拉,可能读到0xA1这种带特征的随机值。我的屏连续读两次都是A1,说明至少通信链路是通的,芯片有在应答,只是应答的内容完全不是ILI9341的ID。这个现象把问题范围缩小到了两种可能:要么SPI时序根本没建立起来,要么屏幕里的主控压根不是ILI9341。
4.2 排查链路:从硬件到SPI时序
排查的第一步永远是量电平。把示波器探头接到MISO引脚,重新上电发送读ID命令,观察有没有正常的脉冲波形。如果MISO线上完全没有数据脉冲,优先查硬件接线,特别是屏的SDO/MISO引脚有没有焊好。SPI是四线通信,很多人只接了SCLK、MOSI、CS、DC,漏了MISO,读ID自然全错。
确认MISO有波形之后,开始查SPI模式。ILI9341对时钟极性和相位的要求是模式0,也就是CPOL=0、CPHA=0,SCLK空闲为低电平,数据在第一个边沿采样。如果STM32那边不小心配成了模式3,读ID结果就会变成一串乱码。检查这步很简单,把屏幕初始化前几行的SPI配置打出来,对比数据手册即可。
还有一个更容易忽略的点:读ID命令字节的位数。ILI9341读ID用的命令是0xD3,标准写法是先发送命令字节,然后主机连续发送三个哑字节(0x00),同时从MISO读取三个字节。但我见过有人在发送命令时用了16位模式,把0x00D3当成一个16位字发出去了,芯片收到的指令序列完全变了。SPI字节序和帧格式在屏幕驱动里最容易踩,建议在读ID函数里显式地逐字节发送,不要用任何DMA批量模式。
4.3 可能藏在背后的“非原厂屏”
硬件、模式、帧格式都排完之后,我最后确认了一个事实:这块屏的控制器不是ILI9341,而是某个兼容驱动芯片。兼容芯片的寄存器布局大体模仿9341,但ID寄存器返回值和标准不同,有些是0xA1A1开头,有些干脆返回0x0000。这类屏在中低端市场很常见,外观、引脚、甚至初始化命令都能和9341混用,唯独读ID这一步会暴露身份。
方案的调整也简单:初始化流程里不要死磕“必须读到0x9341”这个假设。我把读ID函数包成一个isValid(),返回值不管是0x9341还是0xA1A1,只要不是全0xFF或全0x00,就认为屏幕控制器可通信,然后走一套兼容初始化序列。实际使用效果看,只要初始化命令近似,显示颜色和基本绘图功能没有实质差异。这里也顺便解决了“换一批屏就白屏”的尴尬问题——很多兼容屏用原厂初始化序列也能点亮,只是后续深浅色翻转、gamma等细节不同,真在乎这些再单独校准。
4.4 把读ID做成上电自检
操作系统之后,我把读ID逻辑顺手做成了上电自检项。系统启动时依次检查Flash、传感器、屏幕,每个外设封装类都提供一个probe()成员函数,返回布尔值。屏幕的probe()就是这次读ID,读到有效ID返回true,否则返回false。串口日志配合LED,哪个环节失败了能一眼看出来。这个习惯我带进所有项目之后,现场联调的时间明显变短了,与其对着黑屏猜是电源、背光还是驱动问题,不如让设备自己说。
5. 活补完之后收拾代码:从“能跑”到“能交接”
5.1 清理“临时用”的全局变量,给外设排队
三件活补完之后,工程里出现了一些临时添加的全局变量和裸回调函数。最典型的是中断服务函数里顺手用的几个volatile标志位,还有几处跨模块共享的缓冲区。功能是能跑了,但代码读起来像合租屋里到处堆的杂物。我花了一个下午做清理,原则很简单:每个外设一个类,类内部自己管状态,跨模块通信只走事件标志位或队列。
清理时特别留意了中断里访问的数据。中断处理函数里所有共享变量都声明成volatile,这是常识;但在C++类里,成员变量挂在中断和主循环之间访问时,同样要记得加volatile修饰。C++标准对volatile的语义和嵌入式硬件语义不完全一样,实际工程中大家还是习惯用它做中断共享标记,配合临界区保护效果也够用。清理完之后,工程里的全局对象数量从二十多个降到七八个,每个对象职责单一,出错时能快速定位。
5.2 裸机调度还是上RTOS?这个工程怎么选
代码收拾到一半,我停下来认真考虑过一个问题:已有LCD刷新、超声波测距、频率测量、串口日志,要不要直接上RTOS?我的结论是暂时不上。当前的任务量就四五条,且彼此间没有强实时冲突,超声波测量本身已经改成了状态机+中断的模式,主循环只负责周期触发和取数,裸机用一个简单的调度表完全够用。上RTOS会带来额外的任务栈分配、优先级管理、信号量使用成本,对一个单传感器加显示的小项目而言收益不高。
真正值得重视的是任务调度表的设计。我用一个结构体数组把周期任务列清楚:2毫秒处理按键扫描,10毫秒触发超声波,100毫秒刷新LCD,500毫秒打印一次状态。每个任务入口都是普通函数,主循环按最小公倍数时间片轮询一遍。这个调度表写在源代码顶部,以后新功能只需要往表里加一行,可读性和维护性比散落一堆if时间判断好得多。
5.3 嵌入式架构师差的不是画图,是资源边界
“嵌入式架构师”这个词最近被聊得很多。有一种误解是架构师的工作就是画框架图,把模块框起来连上线就算架构设计。干到第6篇这个阶段,我反而觉得架构师真正要盯的是资源边界:每个模块能吃多少RAM、占用哪条总线、中断优先级怎么排、哪些数据只能由哪个任务读写。C++在这里的作用比想象中大,constexpr可以帮助在编译期做参数校验,类的访问控制限制了随意越权访问,RAII可以把外设的开关状态管理起来,这些都比画一张漂亮的框图更实际。
比如测距和LCD刷新共享SPI总线时,边界必须明确:要么串行化访问,要么用一个总线仲裁标志位防止并发。如果当初没有封装类的概念,这段逻辑大概率会变成两个源文件里各写各的SPI_Send,改起来就是一场灾难。代码收尾不是为了好看,是为了下一次加需求的时候,你知道该改哪一处,不用把所有模块重新看一遍。
5.4 后续路径:还差USB这类大活
到这期为止,手头的“零碎活”清得差不多了。下一阶段真正的大活是USB——让STM32以USB设备的方式和电脑通信。这属于不一样的复杂度,涉及枚举、端点、描述符,还有各种类的选择问题,适合单独拿一篇来写。再往后如果想让板子联网,还得考虑加网卡或者连云平台,那些又是另一套栈的知识。眼下这期内容把测频、测距、显示三块地基填完,后续不管是做手持仪器还是做个桌面小设备,底气都足了。
最后聊点个人体会。每次排完这类“还差的活”,收获最大的往往不是功能跑通那一刻,而是清理过程中发现自己代码里有多少“暂时能用”的东西其实在埋雷。中断里写的裸标志位、只在一个地方用到的全局变量、靠延时拼出来的时序,这些“活”被补完,项目才算真正站稳了。建议你们也打开自己的工程看一眼,列一个差不多的清单,别再跟键盘较劲,先跟欠的账较劲,这笔账越早还,后面路越顺。