
哎写到第六篇了哟哟哟咱们还差活滴——这个标题我自己看着都觉得有画面感。前面五期我们一路用C啃STM32的各种外设GPIO点灯、串口通信、定时器中断都玩过一遍了但每次收尾时我心里都清楚单个Demo跑通和真正做完一个项目之间还隔着一堆说不上来哪里不对劲的活。功能都在拼起来就打架代码能跑改一下就要命原理都懂一上电就翻车。这期我干脆把差的那点活一起补掉用一个不大不小的综合项目超声波测距、定时器输入捕获测频率、ILI9341液晶显示三合一顺带把工程组织和调试工具链也整顿一遍。适合已经能把单个外设跑起来但感觉离完整作品还差一步的朋友。1. 这一期到底要补哪些活先盘一下手头的家底1.1 前面五篇留下的能力底子我先做个大概的假设前五期的内容我们分别覆盖了用C封装GPIO控制LED、串口printf做日志和简单命令交互、基础定时器做毫秒级调度、中断优先级与NVIC的配置思路以及如何把代码拆成模块而不是一个大main函数。这些内容合起来其实已经覆盖了一个小型嵌入式项目百分之八十的基础外设操作。但为什么大家还是会在实战里卡住我统计了一下收到的问题最高频的不是某个外设不会调而是三个字——连不上。这个连不上有很多种LCD初始化不出来读ID读到乱码超声波测距偶尔跳一个离谱的值CAN总线明明昨天还好好的今天上电就进不了中断看门狗一开调试器一停就复位。这些都不是原理不会而是模块与模块之间开始产生干扰的时候你缺少一套排查思路。这期就是干这个用的。1.2 三个典型的差活缺口我总结出三个最常见的缺口。第一模块协作关系不清楚。测距用定时器捕获LCD用SPI频率测量也要定时器三个外设同时要中断优先级怎么分配DMA和中断会不会互相踩很多人到这一步就开始在main函数里堆代码堆到后面自己都看不懂。第二C封装只停留在单类层面。单个外设一个类写起来很爽但一旦两个类都要访问同一个底层的定时器句柄、三个类共享同一个串口打印代码就开始各搞各的没有数据流设计的概念。第三边界条件处理不足。量程上限、计数溢出、复位时序、电平匹配这些细节不处理项目就是实验室能跑现场就跑不了。所以这一期我的目标很简单把这些典型的差活集中到一个项目里一个个补给你看。1.3 这期的目标项目环境信号采集与显示这个项目我选得不算难但足够代表性用HC-SR04超声波模块测距离用STM32F103的定时器输入捕获去测量外部方波频率把结果实时显示在一块ILI9341液晶屏上同时通过串口打印原始计数值用于校准。麻雀虽小传感器、定时器、显示、通信全占齐了而且每一个环节都有说法。硬件连接我后来调整过好几版下面直接给你最终稳定版。模块引脚STM32资源HC-SR04 TrigPB0普通GPIO输出用于触发测距HC-SR04 EchoPB1TIM3_CH4输入捕获测量回波脉宽外部方波输入PA0TIM2_CH1输入捕获测量信号频率ILI9341 LCDSPI1PA5SCK、PA6MISO(可不用)、PA7MOSI外加DC/CS/RST控制引脚串口调试USART1PA9TX、PA10RX输出日志和原始数据这个引脚分配是我踩过坑之后定下来的。最初我把超声波Trig和Echo放在了PA6和PA7上结果和SPI1的MISO/MOSI撞了个正着。你想象一下一边是LCD要刷屏一边是超声波在测距两边的信号在同一个引脚上打架的场面。所以从最开始规划引脚就要先把SPI、定时器通道、串口的复用关系列出来再决定外设放哪儿。2. 用C给外设建模一个定时器输入捕获类的完整设计2.1 为什么要封装而不是直接在main里写寄存器很多朋友学嵌入式C最容易犯的一个错误是明明用的编译器是g代码风格却还是C语言那种一个大函数从头写到尾。不是说C不行而是当项目从几百行膨胀到几千行时全局变量的耦合会让你改一处崩三处。给定时器输入捕获封装成一个类好处有两个。一是每个定时器实例都是独立对象你初始化TIM2和TIM3就是创建两个CaptureTimer对象行为互不干扰代码读起来一目了然。二是回调函数变成了对象成员方法可以访问对象自己的状态不用靠丑陋的全局变量中转状态。但这里有一个嵌入式特有的细节中断回调是C语言时代的产物。HAL库在stm32f1xx_it.c里会把中断统一转到HAL_TIM_IC_CaptureCallback这个弱函数里而这个函数是C链接的不能直接当成C类的成员函数来接收。所以你需要在外面包一层转发器。2.2 一个可用的InputCaptureTimer类框架我建议的类结构大概是这样的class InputCaptureTimer { public: using Callback void(*)(uint32_t count_value, uint32_t counter_freq_hz, void* user_ctx); InputCaptureTimer(TIM_HandleTypeDef* htim, uint32_t channel); void init(uint32_t arr, uint32_t psc); void start(); void stop(); void registerCallback(Callback cb, void* user_ctx); private: TIM_HandleTypeDef* htim_; uint32_t channel_; Callback cb_ nullptr; void* ctx_ nullptr; friend void input_capture_irq_entry(TIM_HandleTypeDef* htim); };然后配套一个C链接的转发函数放在.cpp文件里static InputCaptureTimer* capture_timer_instances[8] {nullptr}; extern C void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef* htim) { for (int i 0; i 8; i) { if (capture_timer_instances[i] capture_timer_instances[i]-htim_ htim) { uint32_t count HAL_TIM_ReadCapturedValue(htim, capture_timer_instances[i]-channel_); capture_timer_instances[i]-cb_(count, capture_timer_instances[i]-htim_-Instance-PSC 1, capture_timer_instances[i]-ctx_); } } }注意看这个设计思路类本身不直接跟HAL回调发生耦合而是通过一个静态数组注册表做映射。这样多个定时器实例可以并存HAL_TIM_IC_CaptureCallback进来之后按定时器句柄查到对应的实例再调用实例自己的回调。很多嵌入式C框架都是这个套路它比用一个全局函数指针要灵活得多。2.3 中断回调用函数指针还是std::function一开始我确实试过用std::function写起来是真的舒服lambda一捕获就能当回调代码很现代。但你知道吗在STM32F103这种72MHz主频、20KB RAM的芯片上std::function会引入类型擦除和潜在的动态内存分配。如果你的项目里同时开了RTOS和一堆驱动RAM余量本来就不宽裕编译完一看.bss段涨了一截心里就开始慌了。我的建议是对中断通知这类对延迟敏感、调用频率高的场景用裸函数指针加user_ctx指针。汇编层就是一次间接跳转零额外开销。如果不那么在乎中断延迟或者芯片资源充裕比如F407、H743这种用std::function换代码可读性也不是不可以。但至少我自己的习惯是能少用动态特性就少用。还有一个细节访问外设寄存器时C里一定要用volatile。HAL库其实已经用__IO宏帮你处理了但你如果自己写寄存器地址映射类比如这样struct GPIO_Regs { volatile uint32_t CRL; volatile uint32_t CRH; volatile uint32_t IDR; volatile uint32_t ODR; };千万别丢了volatile。编译器在做优化时默认认为内存地址不会自己变化没有volatile的话它可能把多次读取优化成一次导致你读状态寄存器永远读到旧值。这个坑我在早期用C写寄存器映射时踩过不止一次。2.4 实际使用中的状态管理类有了还要考虑一个问题定时器捕获到边沿之后你需要在回调里切换捕获极性。测距的场景就是先捕获上升沿记下当前计数值再把捕获极性切换成下降沿等下降沿到来再读一次计数值两次相减就是高电平脉宽。这个逻辑如果放在主循环里做时序早就飘了必须放在中断回调里而且要保证切换极性的代码是原子的——在HAL库里就是先读状态寄存器再写捕获极性控制位中间不能被另一个中断打断。所以我在项目中把TIM3的中断优先级设得比较高避免被串口中断打扰。3. 综合项目拼装超声波测距、频率测量与LCD显示的联动3.1 硬件连接与资源分配先把硬件连线说清楚我上面给过表了这里再补充几个实操细节。HC-SR04的Trig引脚接PB0这个脚只做普通GPIO输出测距时给它一个大于10微秒的高电平模块内部就会发8个40kHz的超声波脉冲然后Echo引脚输出一个高电平脉冲宽度就是声波往返的时间。PIN脚方面Echo接的PB1对应TIM3_CH4正好用定时器输入捕获去量脉宽。这里我要特别提醒一句HC-SR04的Echo输出的是5V电平而STM32F103不是所有引脚都能容忍5V。哪怕PB1在某些型号上标注了FT5V容忍你也别去赌这个。我的做法是串一个分压网络Echo串一个2k电阻到PB1PB1再并一个3.3k电阻到地把5V近似分到3.3V左右。不贵但能防止模块在距离很近、回波幅度最高的时候把IO口烧掉。外部方波信号进PA0也就是TIM2_CH1这个脚在F103标准版上是可以承受5V的但同样建议做一下电平适配再接入。测频范围我实测是1Hz到50kHz都能稳定更高频率就要换方案了。3.2 数据流与任务调度设计整个系统我设计成主循环状态机定时器中断采集的结构没有上RTOS因为这个项目规模还不需要。主循环每200毫秒做一次超声波触发每次触发后等待Echo捕获完成然后更新距离值TIM2持续工作在输入捕获模式每个上升沿触发一次中断在中断里计算频率测距和测频的结果都放进一个共享结构体LCD刷新函数每500毫秒取一次数据把距离和频率画到屏上。为什么主循环每200毫秒触发超声波而不是越快越好HC-SR04单次测距的时间等于声波往返时间按量程4米算最长需要约23毫秒加上余量触发间隔给到50毫秒以上才安全。我第一次测试图快直接设成30毫秒结果屏幕上距离值一直在抖。后来改成150毫秒间隔加了一个简单的滑动平均滤波数据才稳定下来。这个经验就是传感器有它自己的工作节奏你强行高频触发得到的是更多的噪声而不是更高的精度。3.3 关键代码片段Echo脉宽和频率测量测距部分的回调示意如下这里只写核心逻辑extern C void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef* htim) { if (htim-Instance TIM3 HAL_TIM_GetActiveChannel(htim) TIM_CHANNEL_4) { static uint32_t rise_ticks 0; if (echo_measuring 0) { // 第一次触发应该是上升沿记下时间然后切到下降沿 rise_ticks HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_4); __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_4, TIM_INPUTCHANNELPOLARITY_FALLING); echo_measuring 1; } else { // 下降沿到来脉宽 下降沿计数值 - 上升沿计数值 uint32_t fall_ticks HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_4); echo_pulse_ticks fall_ticks - rise_ticks; __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_4, TIM_INPUTCHANNELPOLARITY_RISING); echo_measuring 0; } } }如果你切换极性后不再触发中断检查一下是不是通道在切换后需要重新使能这个在不同HAL版本里有差异。我的做法是在切换极性后顺手调用一次HAL_TIM_IC_Start_IT(htim, TIM_CHANNEL_4)保险起见反正开销也不大。频率测量更简单每次上升沿读一下TIM2的计数器和上次的值相减就是信号周期频率等于定时器时钟除以这个差值。注意处理计数器为0和回绕的情况我加了一个溢出计数扩展把16位计数器扩展成32位来用这样低频信号也能准确测量。3.4 实测中遇到的三个时序问题第一个是超声波毛刺。回声信号受环境和模块本身的噪声影响偶尔会出现一个非常窄的假脉冲导致距离值随机跳到一个很小的数。我的滤波策略是设置最小脉宽门槛只有Echo高电平持续时间超过一定阈值比如100微秒对应约1.7厘米才认为有效。低于这个宽度的信号直接丢弃。这个门槛值可以做成可调参数方便不同环境下的校准。第二个是定时器溢出。低频信号比如10Hz两次上升沿之间的间隔是0.1秒按TIM2的72MHz时钟算需要7200000个计数远超16位定时器的上限。所以必须在溢出中断里把高位计数器加一再把两次读取合并成32位值。这里我提个建议优先使用可以级联的定时器或者带有32位计数器的型号如果只能用16位定时器溢出扩展逻辑要写得正确且高效。第三个是LCD刷新和数据采集用了同一个SPI总线时的时序问题。LCD显示一帧数据需要持续占用SPI如果超声波或频率测量也想通过SPI跟外部交互就会出现抢占。我的项目里测量不依赖SPI所以冲突不大但你如果接了SPI接口的传感器就要考虑SPI外设的互斥访问了。4. 那些看着简单、一调就翻车的边界细节4.1 定时器捕获测频率的误差边界用定时器捕获测频率核心公式是f TimerClock / (count_now - count_last)。误差来源主要有三个方向。定时器时钟本身的精度是最基础的如果你用的是内部RC振荡器不同温度下频率可能偏1%到2%。对频率测量有要求的场景必须用外部晶振HSE并确认PLL配置正确。其次是捕获时刻的抖动中断响应延迟会导致读数偏差几个时钟周期72MHz下一个tick约13.9纳秒这个抖动在高频测量时会被放大。再次是占空比极端的信号测频率应该固定用上升沿到上升沿不要用上升沿到下降沿去算否则受占空比影响。我自己的实测数据是在100Hz到50kHz范围内这个方案误差能做到0.1%以内。再往上到MHz级别就用输入捕获的PWM模式或者换更高速的定时器代码方案不变但硬件资源要升级。4.2 超声波回波误判与测量上限HC-SR04标称量程4米实际2.5米以外回波衰减就很明显了容易出现丢波或误判。我踩过的一个坑是触发测距后如果前方没有障碍物Echo应该一直保持低电平但模块内部的回波处理电路偶尔会因噪声产生一个窄脉冲导致距离值随机跳到一个很小的值。除了前面说的最小脉宽过滤还可以在距离变化上加一个速率限制比如相邻两次测量的距离变化超过0.5米就认为是异常值丢弃。这个方法在动态环境里尤其好用。还有一点超声波测距易受温度和空气流动影响。温度每变化1摄氏度声速大约变化0.6米每秒20摄氏度和30摄氏度下测同一面墙距离能差出小几个毫米。如果项目对环境温度有要求建议加一个温度传感器做个声速补偿公式也很简单speed 331.4 0.6 * temperature_deg_c。4.3 ILI9341读ID读到A1A1的排查思路热搜词里stm32使用ili9341读id是a1a1这个话题出现过不少次我也遇到过。ILI9341的控制寄存器读出来应该是0x9341但很多人读出来是0xA1A1。我排查过一次最终定位到两个原因。第一个是SPI读写方向的问题。ILI9341的ID读取命令需要MCU在同一个CS下先发命令字节再切换SDA方向读取数据。如果你用的是软件SPI代码里通常只配置了MOSI输出没有做MISO输入方向切换那读回来的数据就会全部变成固定电平表现出来就是0xA1A1这类重复字节。第二个是复位时序。LCD控制器的复位脚必须拉低至少10毫秒再拉高然后再等一段初始化时间。如果复位时间不够控制器状态机还没准备好读ID就会返回错误值。我当时把复位延时从5毫秒加到20毫秒问题立刻消失。所以看到A1A1先别怀疑芯片是假的先查SPI方向切换和复位时序。4.4 调试器与看门狗的打架这个问题在加了独立看门狗IWDG之后几乎必现。你连上调试器在某个断点停下来看变量停了几十秒看门狗计数器到头整个MCU被复位。这是因为断点暂停的是CPU内核但看门狗由独立的低速时钟驱动CPU停了它还在跑。解决方法有三个层次。最粗暴的是调试期间不喂狗把IWDG在初始化阶段用一个编译开关关掉。好用一点的是利用F103的调试冻结功能__HAL_DBGMCU_FREEZE_IWDG()这条语句让内核停在调试模式时独立看门狗也跟着停断点停多久都不会复位。我在STM32CubeMX里会把DBGMCU的冻结配置打开再把调试器的复位模式设置成不复位一劳永逸。第三个层次是设计层面喂狗的地方不要放在某个任务里而是放在中断的低层级中这样即使某个任务卡死狗也能得到喂食后续调试定位也简单。4.5 CAN通信突然连不上的常见原因有朋友在热搜词里留了stm32 can通信突然连不上这种问题表面看是软件bug实际查下来硬件因素占大头。我总结这几个方向收发器比如TJA1050的GND没有和MCU共地、总线两端缺少120Ω终端电阻、电缆过长导致反射干扰、波特率配置不符合CAN协议允许的相位容差。软件层面还有一个容易忽略的点两边标称波特率一样并不代表它们就一定能通信。CAN的位时序里SYNC_SEG、BS1、BS2分配不同即使最终波特率相同采样点位置也不一样。比如一边配置BS18、BS23另一边BS19、BS22双方对位的采样时刻偏差累积起来总线就可能频繁报错。排查思路是先把两边配置完全统一再挂CAN分析仪看波形如果波形失真优先查终端电阻和线缆。我的经验是两块开发板直接对接时在两端各接一个120Ω终端电阻真是立竿见影。5. 工程化收尾从Keil迁到VSCodeCMake的实践5.1 为什么我最终还是换到了VSCode前面几篇我一直说Keil挺好单文件小项目里确实是加分项。但项目一旦超过二十个文件Keil的工程管理就开始拖后腿。最难受的是跨平台你在Windows上用Keil写的工程拿到Linux的CI上就编译不了而CMake加arm-none-eabi-gcc是跨平台的标准组合。另一个理由是C代码在VSCode里的索引和补全体验对比Keil自带的编辑器是碾压级的。虽然没有集成开发环境那种一键配置的方便但熟练之后效率确实高很多。5.2 VSCode开发环境的搭建命令行骨架我不打算一步步截图直接把可复制的骨架给出来。第一步装扩展C/C、Cortex-Debug、CMake Tools。第二步装ARM交叉编译工具链我目前用的是11.x版本的arm-none-eabi-gcc。第三步装J-Link驱动。第四步写一个CMakeLists.txt核心内容大概长这样cmake_minimum_required(VERSION 3.16) project(stm32_cpp_project C CXX ASM) set(CMAKE_SYSTEM_NAME Generic) set(CMAKE_SYSTEM_PROCESSOR arm) set(CMAKE_C_COMPILER arm-none-eabi-gcc) set(CMAKE_CXX_COMPILER arm-none-eabi-g) set(CMAKE_ASM_COMPILER arm-none-eabi-gcc) add_compile_options(-mcpucortex-m3 -mthumb -O2 -g -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti) add_executable(${PROJECT_NAME}.elf src/main.cpp src/input_capture.cpp src/lcd_spi.cpp src/hc_sr04.cpp src/stm32f1xx_it.cpp startup_stm32f103xb.s ) target_link_options(${PROJECT_NAME}.elf -Wl,--gc-sections -T STM32F103XB_FLASH.ld)这里有一个需要注意的地方-fno-exceptions和-fno-rtti是嵌入式C的惯例。打开这两个选项后代码体积和栈开销都能明显下降代价是你不能用try/catch和dynamic_cast。反正嵌入式项目里我几乎不用这两个特性关掉反而更安心。5.3 J-Link下载配置里的两个小坑第一个坑是调试窗口里看不到外设寄存器。Cortex-Debug插件需要SVD文件才能解析芯片寄存器不然你只能看CPU寄存器。在launch.json里加上svdFile: ${workspaceFolder}/STM32F103.svd调试时就能实时看GPIO、TIM和USART的寄存器位域变化排查外设问题效率完全不同。第二个坑是下载失败。最常见原因是芯片Flash保护位被打开或者是上一次烧录的代码把SWD引脚复用掉了。处理办法是用J-Link Commander执行解锁命令STM32系列一般是执行unlock STM32F1xx然后重新擦除整片Flash再做一次全片烧录。另外如果调试器连接正常但无法进入调试模式检查SWD接口的线和目标板供电是否稳定SWD对线材质量比JTAG敏感线太长或者杜邦线质量差都会导致奇奇怪怪的连接失败。5.4 一个实用的小技巧用ninja加速构建CMake Tools默认会用Unix Makefiles或者Ninja。Ninja在增量编译上比Make快不少尤其是项目源文件多的时候。我在CMake配置里指定了Ninja生成器实际编译速度提升肉眼可见。配置方式很简单VSCode的CMake Tools会在你选择生成器时让你选选Ninja就行。如果你的工具链没有带Ninja单独装一个也很方便。这个优化不花钱收益却很大。6. 从板子能跑到架构合理写给想往架构师方向走的人6.1 嵌入式架构师到底在做什么热搜词里嵌入式 架构师热度不低但很多人对这个岗位的理解是画很多框图、评审别人代码。我的理解不一样。架构师的核心工作是在资源受限的条件下做取舍同时把硬件抽象层做薄做对。什么叫做薄就是上层业务代码不直接碰寄存器驱动类的接口像init()、read()、start()这些稳定且简单。换芯片平台时你只需要改驱动层的实现业务层代码一行不动。这叫隔离。做对就是你的抽象能覆盖绝大多数使用场景而不是为了抽象而抽象。6.2 我建议的进阶路线读完这套系列下一步可以这样走。先把目前手上的小项目用类重写一遍把所有直接操作寄存器的代码收进驱动类。接着读两到三个开源嵌入式项目注意不是看功能而是看头文件划分、目录组织、回调注册方式。然后试着用接口基类去做传感器抽象比如定义一个AbstractSensor超声波、温湿度、光线传感器都继承它主循环里用一个数组轮询就能调度所有传感器。最后挑一个带RTOS的项目学一学任务栈规划、消息队列替代全局变量、互斥量保护共享数据这些做法。6.3 坚持用C写嵌入式的一个理由这个系列从第一期开始就一直在用C我不否认C在嵌入式里的统治地位但C在类型安全和代码组织上的优势在两千行以上的项目里体现得非常明显。只要你主动规避异常、RTTI、动态内存这些重型特性C可以做到和C一样的低开销却能让代码更接近人的思维。给未来的自己留一份能看懂的代码这本身就是不差活最重要的一部分。很多人说嵌入式C难难的不是语法而是时刻要想着这句语法会在编译后生成多少指令、占用多少内存有了这个意识才算真正入门。最后补一句这期补完哟哟哟咱们还差活滴这个梗应该就能翻篇了。剩下的活比如把USB设备功能加上去、引入RTOS做多任务、做更复杂的传感器融合已经是下一段旅程。但按我个人的经验光看不练永远是差活。建议你把我这期讲的几个坑项目式地复现一遍——花一个周末把超声波、定时器捕获、LCD显示和VSCode调试串起来跑通之后再回头看这些文字体会完全不一样。