这个系列的标题越来越不正经但活是真的。“基于STM32的嵌入式C编程之旅”写到第六篇前面的框架、启动、封装都已经铺得差不多了剩下的就是那些“看着不起眼实际一调就想骂人”的功能点。这一篇就叫“还差活”把项目中真正缺的那几块补上——LCD读ID、多通道ADC切换、串口DMA接收、按键非阻塞扫描、输入捕获测频顺带聊聊那些让人搜到头秃的热门问题。别小看这些“散活”它们恰恰是嵌入式开发里最耗时间、最容易把项目拖垮的部分。1. 什么叫“还差活”从框架到功能的最后一公里1.1 C 工程搭好了还差什么功能写这个系列的时候我一直在强调一个观点嵌入式 C 不是把类写得多漂亮而是用类把底层细节收拾干净让应用层能踏实干活。到了这一篇工程里已经有了时钟树、GPIO 包装、串口基础驱动、断言和错误处理看起来骨架很完整。但是骨架完整和“能跑业务”之间隔着一条很宽的河。比如屏幕不亮、ADC 值乱跳、串口收不到完整帧、按键抖动、频率测不准这些才是真正让项目“还差活”的地方。它们不涉及高深理论却全是实打实的调试经验网上搜出来的答案还经常互相矛盾。所以这一篇我不打算讲什么新框架只挑几个“热词榜单”上的高频问题逐个拆开。你会发现大部分坑的根源并不复杂往往是时序、采样时间、状态机设计或者一个寄存器没配到位。1.2 热词背后的真实痛点先说一个我猜很多人都搜过的词ILI9341 读 ID 是 a1a1。屏幕接上去能点亮背光却读不到面板 ID这几乎是每个用 STM32 驱动 TFT 屏幕的人都会撞上的墙。再比如 ADC 切换通道后第一个值总是偏的、串口不定长数据收不完整、按键怎么消抖都还会有误触发、定时器测频率在低频和掉速之间反复横跳……这些热词有一个共同特点它们对应的是“功能模块”而不是“原理宏篇”。换句话讲搞定它们不需要你再啃一遍《C Primer》反而需要你回到数据手册和时序图里去抠细节。这也是为什么我这一篇通篇用的是“实操记录”的方式写而不是教科书式地铺概念。整套思路就一句话先把基础功能做成看得见、摸得着的“活”再来谈架构和抽象。没有这些功能垫底再漂亮的 C 封装都是空中楼阁。2. 排查 ILI9341 读 ID 返回 0xA1A1从指令到时序一步步来2.1 0xA1A1 是怎么来的ILI9341 是一颗非常常见的 TFT LCD 驱动 IC支持 0x04Read ID和 0xD3Read ID4这类读命令。正常情况用 0xD3 会拿到一串包含厂家 ID 和模块版本的数据比如 0x00、0x93、0x41 之类。读到 0xA1A1说明 MISO 上回传的数据根本不是屏幕给的更可能是 SPI 时序根本没对上主机只是在读自己的错误数据。为什么网上有人用它当判断依据因为 0xA1 这个字节出现得很规律像是数据线悬空或者时序错位时稳定“复读”的值。你要做的不是记住 0xA1A1 这个现象而是理解什么条件会导致读不出正确 ID。首当其冲的是 SPI 模式。ILI9341 的数据手册里强调支持 SPI Mode 0CPOL0、CPHA0和 Mode 3CPOL1、CPHA1。但很多开发板的初始化模板默认用 Mode 3如果你手上的屏是 Mode 0 的接线和时序读 ID 就会翻车。另外读写高位在前/低位在前如果配反了也会得到一串“看似有规律但完全不对”的数据0xA1 这种特征值就是典型表现。然后是指令间隔。ILI9341 对读命令的响应不是“立刻输出”命令和第一个字节之间需要一定的延时。有些初始化代码发送读命令后没有任何等待就直接连读MISO 还没拉起来读回来的全是上一轮残留电平自然就成了 0xA1A1。2.2 排查步骤与修复方法我的建议是走一套固定排查流程而不是随机改配置碰运气。第一步把 SPI 时钟降下来。把分频系数调大比如从 18MHz 降到 1MHz 左右再用示波器或者逻辑分析仪看数据波形。很多读 ID 失败是时钟太快、上升沿采不到稳定电平导致的。降速之后能正常读到 ID就说明问题出在高速时序上后面可以再慢慢提速。第二步确认 SPI 模式和位序。初始化 SPI 时同时设置 CPOL、CPHA比如模式 0 对应 CPOL0、CPHA0方向是 MSB First。如果你用的是 STM32 HAL代码里可以直接看到SPI_InitTypeDef spiInit {}; spiInit.Mode SPI_MODE_MASTER; spiInit.Direction SPI_DIRECTION_2LINES; spiInit.DataSize SPI_DATASIZE_8BIT; spiInit.CLKPolarity SPI_POLARITY_LOW; spiInit.CLKPhase SPI_PHASE_1EDGE; spiInit.NSS SPI_NSS_SOFT; spiInit.BaudRatePrescaler SPI_BAUDRATEPRESCALER_64; spiInit.FirstBit SPI_FIRSTBIT_MSB;注意其中的SPI_POLARITY_LOW和SPI_PHASE_1EDGE就是 Mode 0。如果你原来是SPI_POLARITY_HIGH和SPI_PHASE_2EDGE改成这一组配置再试。第三步读命令和读序列要写对。用 0xD3 时发送完命令之后先读第一个字节可以丢弃再连续读三到四个字节面板 ID 一般出现在后面。另外要确认你的 D/C 引脚在命令阶段拉低、数据阶段拉高。很多新手把命令也当成数据发出去屏幕自然不知道你在读它。第四步检查硬件连接。CS 片选、SCLK、MOSI、MISO、D/C、RESET 这六根线是基本盘。特别提醒读 ID 必须用 MISO如果你当初买的是“只写不读”的屏幕型号或者 MCU 的 SPI MISO 引脚被复用成了其他外设读回来 0xA1A1 就是常态。可以先把其他外设关掉单独测试 SPI。2.3 一个让你重新理解 SPI 的教训我在项目里真正被 0xA1A1 卡了两天最后发现是上电时序问题。屏幕的 RESET 引脚是 MCU 控制的但初始化代码里发完复位命令后只延时了 20ms。换成 120ms 之后整个读 ID 流程就通了。别小看这个“呼吸时间”ILI9341 内部上电稳定需要一段时间太早操作寄存器它根本没有进入可以响应命令的状态。这里还要提醒一句并不是所有返回非标准 ID 的屏都是 ILI9341。有些国产兼容屏内部是 ST7789 或者别的控制器你用 ILI9341 的读命令去读得到的就是一个怪值。遇到这种情况先按屏幕型号确定控制器再去匹配初始化序列别死磕“为什么和样例不一样”。如果你手头有逻辑分析仪建议把 SCK、MOSI、MISO 三根线同时抓上。ID 读错了你马上就能看到 MISO 在命令阶段是不是一直低电平。硬件调试就是这样现象明确之后原因其实并不玄。3. 多通道 ADC 切换稳定采样比你想的更讲究3.1 为什么“切换通道”会出问题STMF1 系列内部 ADC 只有一个采样保持电容切换通道后电容需要时间稳定到新通道的信号电平。如果刚切完通道立刻读数据读到的还是上一个通道的残留值尤其在高阻抗信号源上表现特别明显。很多人在网上搜“STM32 ADC切换通道”搜出来的答案都是教你把ADC_ChannelConfTypeDef重配一遍然后重新启动转换。但我要先泼一盆冷水对 F1 来说频繁启停 ADC 反而更容易踩坑。手动切换通道需要关 ADC、配置通道、再开 ADC每次都要等稳定时间。更稳的做法是用规则序列加 DMA让 ADC 自己按顺序扫完所有通道直接通过 DMA 把结果搬进内存应用层只负责读完全不需要“切换”动作。3.2 C 驱动的多通道实现我习惯把多通道 ADC 封装成一个类对外暴露的接口是“读第几个通道的电压”内部细节全部隐藏。这里用 ADC1 的三个通道举例规则组序列顺序是通道 0、通道 1、通道 2开启扫描模式、连续转换、DMA 循环模式。class AdcMultiChannel { public: AdcMultiChannel() { Init(); } void Init() { __HAL_RCC_ADC1_CLK_ENABLE(); __HAL_RCC_DMA1_CLK_ENABLE(); ADC_HandleTypeDef adcHandle {}; adcHandle.Instance ADC1; adcHandle.Init.DataAlign ADC_DATAALIGN_RIGHT; adcHandle.Init.ScanConvMode ADC_SCAN_ENABLE; adcHandle.Init.ContinuousConvMode ENABLE; adcHandle.Init.NbrOfConversion 3; adcHandle.Init.DiscontinuousConvMode DISABLE; HAL_ADC_Init(adcHandle); // 规则序列里的顺序与通道号是两回事 SetChannel(ADC_CHANNEL_0, 0); SetChannel(ADC_CHANNEL_1, 1); SetChannel(ADC_CHANNEL_2, 2); // DMA循环搬运目标缓冲区就是sampleBuffer_ HAL_ADC_Start_DMA(adcHandle, (uint32_t*)sampleBuffer_, 3); } void SetChannel(uint32_t channel, uint32_t rank) { ADC_ChannelConfTypeDef cfg {}; cfg.Channel channel; cfg.Rank rank; cfg.SamplingTime ADC_SAMPLETIME_55CYCLES_5; HAL_ADC_ConfigChannel(adcHandle_, cfg); } uint16_t GetRaw(uint8_t index) { return sampleBuffer_[index]; // index 对应规则序列里的第几个 } float GetVoltage(uint8_t index) { return GetRaw(index) * 3.3f / 4096.0f; } private: ADC_HandleTypeDef adcHandle_; static uint16_t sampleBuffer_[3]; };这段代码的核心是ScanConvMode ADC_SCAN_ENABLE和连续转换。这样 ADC 会自动循环扫描序列里的三个通道DMA 不停地把结果写进sampleBuffer_。你不需要去关心什么通道切换顺序应用层拿到的sampleBuffer_[0]就是序列中第一个通道这里对应 ADC 通道 0的最新值。有几个细节值得展开说说。一是规则序列中的 Rank 和通道号完全没关系Rank 决定先扫谁Channel 决定扫的是谁。二是采样时间设置得长一点比如 55.5 周期对高阻抗信号很友好。三是如果用 DMA 循环模式缓冲区会被反复覆盖如果应用层读得不够快会读到新旧混合的数据。3.3 一个容易踩的数据同步坑很多朋友在 DMA 传输完成中断里把所有数据拷贝一份到应用缓冲区这没问题但要注意半传输中断。开启 DMA 半传输中断时中断触发点可能落在某个通道数据刚写完、其他通道还没更新的位置。如果这时候去读整个 buffer会拿到半帧新数据加半帧旧数据。稳妥的做法是开启传输完成中断全帧完成在全帧完成回调里把sampleBuffer_整体复制到frameBuffer_应用层只读frameBuffer_。我实际上更喜欢“双缓冲”一个 DMA 缓冲区负责接收一个影子缓冲区负责给应用层消费切换靠回调里交换指针。另一个容易忽略的问题是电压基准。直接用raw * 3.3 / 4096是粗略换算ST 内部有 VREFINT 通道可以做一定程度的参考电压校准。如果你做的是需要精度的采集建议把 VREFINT 也放进序列然后每次校正一下。基础版本先跑通再往上加精度这条路是清楚的。4. 串口怎么升级才不浪费 DMA空闲中断 C 接收框架4.1 从轮询到中断到 IDLE串口是嵌入式项目里最缺不了的外设但这个外设“看起来简单用起来糊”。裸机时代很多教程教你轮询收一个字节然后在主循环里判断是不是帧头、帧尾。这种做法在小数据量下能跑数据一多就把 CPU 全吃了。跟着升级一点的是单字节接收中断每个字节进一次中断存入缓冲区。这种方式比轮询好但高频数据来了还是会频繁打断 CPU。真正解决不定长帧问题的方案是 DMA 接收加 UART 空闲中断IDLE。它的思路很直接DMA 负责把串口数据连续搬进内存CPU 完全不用管字节流当总线上一段时间没有新数据时硬件触发一次空闲中断这时代表一帧数据结束了。用 HAL 库做这件事有现成接口比如HAL_UARTEx_ReceiveToIdle_DMA原理类似只是底层处理更透明。我更喜欢自己维护一套因为可控性高还能顺便把“帧边界识别”做成 C 类。4.2 一套可复用的 C 串口接收框架这个框架我拆成三层。第一层是 DMA 接收缓冲区第二层是环形队列第三层是帧解析回调。上层的业务逻辑不需要知道 DMA 怎么配、环形队列怎么读写它只关心“收到一帧完整数据”。核心片段大概是这样的template size_t N class UartFrameReceiver { public: void Init(UART_HandleTypeDef* huart) { huart_ huart; HAL_UART_Receive_DMA(huart_, dmaBuffer_, N); __HAL_UART_ENABLE_IT(huart_, UART_IT_IDLE); } void HandleIdle() { // 读 SR 清标志 uint32_t sr huart_-Instance-SR; (void)sr; // 当前 DMA 已接收字节数 N - DMA 剩余计数 uint16_t received N - __HAL_DMA_GET_COUNTER(huart_-hdmarx); for (uint16_t i 0; i received; i) { rxQueue_.Push(dmaBuffer_[i]); } // 恢复 DMA 接收 HAL_UART_Receive_DMA(huart_, dmaBuffer_, N); } bool PopFrame(uint8_t* frame, uint16_t maxLen) { // 从环形队列里按帧协议提取一帧 // 比如寻找 0xAA 开头、0x55 结尾 return frameParser_.TryParse(rxQueue_, frame, maxLen); } private: UART_HandleTypeDef* huart_; uint8_t dmaBuffer_[N]; RingBuffer256 rxQueue_; FrameParser frameParser_; };这里最关键的一点是DMA 的计数器寄存器会实时告诉你“还剩多少个字节没搬”用N - count就能算出最近一段时间收到了多少字节。空闲中断到来的那一刻就是“一帧收完了”的边界。处理完数据后必须重新启动 DMA 接收否则 DMA 不会自动继续。要注意的是空闲中断属于串口的事件而非 DMA 中断所以它通常会在HAL_UART_IRQHandler里处理。如果你把上面这个类的句柄传到HAL_UART_IdleCpltCallback里调用HandleIdle()整个流程就串起来了。手动写默认没有回调我更建议直接在串口中断函数里做一次判断这样效率最高。4.3 GBK 转 UTF-8 与中文字符串最近老有人搜“STM32 GBK转UTF8”这通常发生在两个场景一个是屏幕要显示中文汉字另一个是采集数据要上报到云平台。先说结论绝大多数情况下你不应该在单片机里做完整的 GBK 转 UTF-8 转换。原因很简单完整码表非常大单片机 Flash 放全量码表非常浪费。更务实的方法是看用途。如果是屏幕显示直接购买带中文字库的屏幕驱动自带 GBK 内码索引如果是 LVGL 这类 GUI 库工程源文件保存成 UTF-8用字库工具生成 UTF-8 编码的字模上层字符串直接用u8你好这种编码底层就不需要转码。如果是物联网上报通常 ESP8266 或者 4G 模组内部大多能直接透传 UTF-8你只要保证 MCU 发的字符串本身就是 UTF-8。所以与其想着在嵌入式里转换编码不如一开始就把工程文件编码和字符串字面量统一成 UTF-8。真的遇到了必须转换的场景我建议只保留常用汉字子集的码表用一个查表函数做映射而不是全量转码。5. 补齐按键与测频状态机扫描和输入捕获的落地代码5.1 状态机按键扫描按键消抖是老生常谈但很多人还是习惯写完一个Delay(20ms)再读一次就完事。阻塞式延时在简单 demo 里行在真实系统里会让整个主循环卡死尤其当你同时还要处理屏幕刷新、串口数据的时候这种写法非常不优雅。我处理按键的方法是写一个非阻塞状态机把它当成一个周期任务每 5ms 扫描一次。扫描周期决定了消抖检测的粒度也决定了状态机的刷新频率。用枚举表示按键状态enum class KeyState { Idle, DebouncePress, Pressed, DebounceRelease }; class KeyScanner { public: void Update(GPIO_PinState pinLevel) { switch (state_) { case KeyState::Idle: if (pinLevel pressedLevel_) { repeat_ 0; state_ KeyState::DebouncePress; } break; case KeyState::DebouncePress: if (pinLevel pressedLevel_) { if (repeat_ 2) { state_ KeyState::Pressed; onPress_(); } } else { state_ KeyState::Idle; } break; case KeyState::Pressed: if (pinLevel ! pressedLevel_) { repeat_ 0; state_ KeyState::DebounceRelease; } break; case KeyState::DebounceRelease: if (pinLevel ! pressedLevel_) { if (repeat_ 2) { state_ KeyState::Idle; onRelease_(); } } else { state_ KeyState::Pressed; } break; } } // onPress_ / onRelease_ 是注入的回调 private: KeyState state_ KeyState::Idle; int repeat_ 0; std::functionvoid() onPress_; std::functionvoid() onRelease_; };这里的核心思想是“连续两次读到相同状态才确认变化”。第一次检测到低电平不能立刻判定按键按下等到下一次扫描仍然是低电平才认为稳定。这样做不但省了阻塞延时还能自然滤掉 10ms 以下的机械抖动。实际项目中可能会有人问主循环扫描周期不是 5ms 怎么办那你需要用一个定时器中断来调用Update()或者至少保证循环周期不大于 10ms。另外按键接 GND 使用内部上拉还是接 VCC 使用内部下拉和初始电平判断、硬件电路必须保持一致。我曾经见过一位朋友把上拉按键配置成下拉结果按键按下反而是高电平状态机死活触发不了。5.2 定时器输入捕获测频率测频率是嵌入式开发里的一个经典需求常见方案有两种测周法和测频法。低频信号适合测周法测量两次上升沿间隔用定时器时钟除以间隔得到频率高频信号适合测频法在一个固定时间窗口内对外部脉冲计数。STM32 定时器的输入捕获天然支持测周法。以 TIM3 通道 1 为例配置上升沿捕获两次捕获的 CCR 值相减就等于一个脉冲周期内经过的定时器时钟数。如果你用的是 16 位定时器还要处理溢出当信号频率很低、周期超过 65536 个定时器时钟时捕获到的两次 CCR 差值会不对需要进行溢出次数扩展。我的 C 封装里会维护一个 32 位的扩展计数器。开启定时器更新中断每溢出一次overflowCount_加 1。在捕获中断里计算当前累计时间uint32_t captured ccrValue (overflowCount_ 16); uint32_t period captured - lastCaptured_; lastCaptured_ captured; float freq timerFreq_ / (float)period;注意一下溢出计数的读取策略在捕获中断里要先读 CCR再读溢出计数否则可能在读的时候刚好发生溢出导致计算错位。实际测试时我还会加一个判断如果两次捕获之间的period异常大或者异常小就丢弃这一次结果。这种“脏数据过滤”虽然不是必须的但在电磁环境差的地方很管用。如果你要同时测占空比STM32 还提供 PWM 输入模式一个通道捕获周期另一个通道捕获占空比。不过 PWM 输入模式会占用同一个定时器的两个通道和普通的输入捕获结构不一样。项目里建议先跑通单通道测频再按需扩展。5.3 和超声波测距、伺服控制的联动这一套捕获代码完全可以复用到超声波测距上。超声波模块的 Trig 引脚发出 10us 高电平Echo 引脚返回一个和距离成正比的高电平脉冲你只需要用定时器输入捕获测出 Echo 高电平宽度就能算出距离。我之前在做这个项目的时候直接调用了同一个 FreqMeter 类里的捕获中断函数只是把计算从“测周期”改成“测脉宽”。伺服电机通过 485 控制也类似关键是串口帧的收发和 CRC 校验。把第四节里的 UartFrameReceiver 和帧解析器组合一下就能组成一个完整的电机控制协议栈。这也是为什么我一直强调嵌入式开发中很多模块其实是“复用 调整”不是每个需求都从零开始写。6. 嵌入式 C 高频坑与工程演进6.1 项目里常见的坑和定位思路把前面几节的内容汇总一下加上一些我踩过的其他问题做成一张排查表。这张表适合贴在工位旁边遇到问题按表排查能省下不少时间。现象可能原因快速定位建议LCD 读 ID 返回 0xA1A1SPI 模式不对、时钟过快、上电延时不够、MISO 未接降速、核对 Mode 0/3、延时至 120ms、检查 MISO 波形ADC 通道切换后首值漂移采样电容未稳定、序列顺序和通道号混淆、DMA 半中断时读数据用规则序列DMA、增加采样时间、全帧完成再复制串口帧偶尔丢尾字节DMA 被覆盖、空闲中断未清标志、重新启动 DMA 过晚空闲中断后立即停止 DMA、拷贝数据、再恢复按键偶尔误触发消抖时间不够、上拉/下拉方向错误、扫描周期不稳定状态机连续两次确认、确认硬件电路、固定 5ms 周期定时器测频低频不准16 位溢出未处理、CCR 读取顺序错误、毛刺干扰扩展 32 位计数、先读 CCR 再读溢出、加入滤波CAN 通信突然连不上总线关闭、终端电阻缺失、过滤器配置问题、CAN2 未挂过滤器检查错误计数器、万用表量终端电阻、重新初始化过滤器CAN 的问题单独多说一句。STM32 有两个 CAN 控制器但在 F1 系列里CAN2 的过滤器必须依附在 CAN1 的过滤器组上。很多人配完 CAN1 能发能收CAN2 却一直收不到数据原因很可能就是没给 CAN2 分配过滤器。总线关闭Bus-off又是另一回事控制器会因为错误太多主动退出总线需要通过软件协议恢复请求否则会一直“连不上”。6.2 嵌入式 C 项目怎么继续演进聊完坑再来聊点工程层面的事。我见过不少嵌入式 C 项目类写了一大堆继承关系叠了三层最后编译出来的固件大得离谱。嵌入式 C 最重要的纪律之一就是别滥用动态内存和虚函数。航天的项目我不敢乱说但在 MCU 这种资源紧巴巴的环境下RAII 和模板是很有用的工具虚函数用得太多则会让每次调用都多一次间接跳转同时对 Flash 和 RAM 都不友好。我更推荐的演进方向是分层。底层是 HAL 或寄存器驱动中间层是对外设的 C 封装比如前面写的 AdcMultiChannel、UartFrameReceiver、KeyScanner最上层才是业务逻辑。业务层只依赖中间层的抽象接口不关心底层寄存器。这种做法的最大好处是方便测试你可以把中间层替换成模拟器或者 PC 上的假实现直接在单元测试里验证业务逻辑。编译器层面开-Os优化加上-ffunction-sections和-fdata-sections配合链接阶段的--gc-sections能把没用到的函数和数据裁掉。如果工具链支持 LTO也建议打开对 C 项目尤其明显。很多模板实例化出来的代码在普通优化下不会自动移除LTO 能把它们整段优化掉。6.3 关于“嵌入式架构师”的一点个人看法网上关于“嵌入式架构师”的讨论越来越多有人晒学习路线有人晒面试八股。我的看法可能不太一样架构师不是靠背概念当上的而是靠一个个真实的“烂摊子”喂出来的。你今天搞明白 ILI9341 为什么返回 0xA1A1搞明白 ADC 切换通道后为什么第一笔不准搞明白 CAN 的 Bus-off 怎么恢复这些积累才是架构判断力的真正来源。架构能力体现为拿到一个新功能你大概知道会踩哪些坑知道哪一层该做抽象哪一层该保持透明知道什么时候该写一个通用类、什么时候该直接写一次性代码。这些东西没法靠看视频学会只能靠做项目把一个个“还差的活”补上。我自己写这个系列最大的感受是每一篇看起来是讲一个具体问题但背后都是一条“从一个现象摸到另一个原理”的路径。0xA1A1 看起来只是屏幕读 ID 失败实际上逼你重新理解了 SPI 的 CPOL、CPHA、上电时序和数据手册的重要性。ADC 的丢数问题实际上逼你重新理解了采样电容、序列扫描和 DMA 的配合。串口丢帧问题实际上逼你重新理解了 DMA 计数器和中断之间的时序关系。所以这一篇的“活”补完之后你的收获不只是几段可复用的代码而是以后遇到类似问题时不慌、有思路的底气。下回要是再“差活”我猜大概率会是显示缓存、文件系统或者更复杂的协议栈。到时候咱们接着聊。
阅读完成 · 觉得有帮助?