这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » DIY与开源设计 » 电子DIY » 【let's do 2026年 第2期】双FRDM-MCXA153主从超声仪表—

共2条 1/1 1 跳转至页

【let's do 2026年 第2期】双FRDM-MCXA153主从超声仪表——把"探头"和"仪表"拆成两块板

菜鸟
2026-09-26 19:30:45     打赏

一、简介

  两块 FRDM-MCXA153,一块当探头、一块当仪表,中间只连三根线;探头可以拿远,读数照样稳。

  • 探头板(slave_probe)只干一件事——驱动 HC-SR04 测距。没有屏幕、没有按键。

  • 仪表板(master_hmi)只干一件事——显示与交互。没有传感器。

  • 板间一根 UART:115200 8N1,只连 TX / RX / GND 三根,不连电源。

  • 协议每帧带从机 ID,改一个宏就多一个探头,链路层不用动。

  • 仪表 LCD1602 显示 D=175.5cm OK / TH=10.0cm AUTO;两个板载按键分别切报警阈值和上报模式;板载 RGB 灯做心跳与报警。

  • 实测整段抓包 1515 帧、异或校验 1515/1515 全通过、0 错误;稳态读数 175.1 cm,波动 ±0.2 cm。

fig1_overview.jpg

图 1 整体实物(录像原始整帧)。左边是仪表板(LCD1602 + 两个按键 + 板载 RGB 指示灯),右边是探头板(HC-SR04 的两个探头朝右),中间那几根杜邦线就是全部的板间连线;两块板各自插自己的 USB

  上一篇是单板方案,一块板同时干测距、LCD 和按键,探头线必须从板子上引出来。因为超声波模块和LCD显示屏我是使用的自备物料,所以我购买了两块主控板,这里就把两块主控板一起用起来。我这里的方案其实不只是为两块主控板设计的,后续还可以接入更多的主控板,更多的超声波模块。以及可以把串口改成I2C,使用开发板是获取不同的ID,可以接入更多的设备。但受于目前只有两块,所以先把框架搭起来,也便于后续拓展。这一篇把"探头"和"仪表"拆成两块独立的板,用一条串行链路连起来。技术底座一样(FRDM-MCXA153 + MCUXpresso SDK + LCD1602 + 软件 I2C),但有几处刻意的差异:

项目上一篇(单板)本工程(双板)原因





板子数量1 块2 块本工程的主题
Echo 处理宽电压模块,TTL 直连HC-SR04 5V 输出,10k+10k 分压按本次硬件规格,5V 直连会超 MCU IO 额定值
Trig / Echo 引脚P3_15 / P3_1P2_4 (D2) / P3_1 (D7)D8/D9 让给板间链路
LCD 驱动软件 I2C(P1_8/P1_9)同样是软件 I2C(P1_8/P1_9)复用已经验证过的接线,零改动
计时方式DWT CYCCNTCTIMER0 硬件定时器 1 MHz不依赖调试单元,脱离调试器也稳定
滤波10 点滑动平均 + 5 cm 阶跃重置3 点中值 + 8 点自适应滑动平均先去掉尖峰,再平滑

二、硬件介绍

2.1 FRDM-MCXA153(用了两块)

项目参数



MCUMCXA153VLH,Arm Cortex-M33,带 FPU,无 DSP 扩展(cm33_nodsp)
存储128 KB Flash / 24 KB SRAM(另有 8 KB SRAMX0 + 4 KB SRAMX1)
板载调试器LPC55S69(MCU-Link OB,丝印 U4),同时提供 USB 转串口
板载外设RGB 用户灯 D15(MHPA2525RGBDT-S)、电源灯 D4、按键 SW1/SW2/SW3、I3C 温度传感器
供电两个 Type-C 口(J8 / J15),板载 LDO U2 现场生成 3.3V

  这块板子我最喜欢的一点是调试器和目标 MCU 是分开的:SW1 只复位目标 MCU,MCU-Link 不受影响,所以按复位键时串口不会断——这一点在后面"开机同步"那个坑里很关键。

2.2 HC-SR04 与那个不能省的分压

  HC-SR04 用起来极简单:Trig 给一个 10 µs 以上的高电平,Echo 就输出一段高电平,高电平时长 ÷ 58 = 厘米数(或者说 1 cm ≈ 58 µs)。

  它有两个必须注意的地方:

  1. Echo 是 5V TTL 输出,而 MCXA153 的 IO 不是 5V 耐受(但是如果有些特殊的模块,比如我自备的这一款,它是支持3.3伏到5伏的一个区间的,所以我这里可以直接接)。中间必须分压:

  模块 Echo (5V) ──[ 10k ]──┬──────────► P3_1 (D7)  MCU 输入
                            │
                         [ 10k ]
                            │
                           GND
  分压后高电平 = 5V × 10k/(10k+10k) = 2.5V
  → 低于 3.3V 供电,且高于输入高电平门限(约 2.31V),安全且可靠

  分压后的 2.5V 距离 3.3V 有 0.8V 余量、距离 2.31V 门限有 0.19V 余量,看着"刚好够"。所以固件里 Echo 引脚配置成无内部上下拉(kPORT_PullDisable),让分压电阻自己决定电平——内部上拉会跟 10k 分压电阻并联,把分压比拉偏。

  1. 它的发射脉冲本身就有 200 µs 左右(8 个 40 kHz 周期)。200 µs ÷ 58 ≈ 3.4 cm。这个数字请记住,它是后面"坑 3"的物理起点。

2.3 LCD1602 + PCF8574

  用最普通的 I2C 背包,软件 I2C 跑 100 kHz,引脚开漏 + 内部上拉,地址自动探测 0x27 / 0x3F。

  这里有个容易烧 IO 的坑:市面上 PCF8574 背包通常在 SDA/SCL 上各有一个 10k 上拉到 VCC。如果背包用 5V 供电,总线空闲时 SDA/SCL 会被拉到 5V,而 MCXA153 的 IO 不是 5V 耐受。三种处理方式任选:

方案做法评价




A(最稳)背包 5V 供电 + SDA/SCL 各串一个双向电平转换(TXS0102 或 BSS138 模块)对比度好,电平规范
B(推荐,最省事)背包 5V 供电,拆掉/断开背包上那两个 10k 上拉,改用 MCU 内部上拉(3.3V)本工程默认按这个配置调优
C(最简单)背包直接 3.3V 供电无需改动,但对比度可能偏淡,要调电位器

三、方案框图与设计思路

3.1 系统框图

fig2_blockdiagram.png

图 2 系统框图。左边探头板只管测距和上报,右边仪表板只管显示和交互,中间一条 LPUART2 双向链路;两块板各自还有一条独立的 USB 虚拟串口用于打印日志

3.2 为什么要把探头和仪表拆开

  这是整个方案的唯一动机,值得说清楚。

  HC-SR04 的 Echo 是微秒级信号(1 cm ≈ 58 µs),而 LCD 刷新、USB 打印是毫秒到十毫秒级的慢活。如果放在同一块板上做,测距很容易被显示和打印打断,读数就抖。

  拆成两块板之后,两端在时间和物理上同时解耦:

  • 从机侧:Echo 脉宽由硬件定时器 CTIMER0(1 MHz,1 计数 = 1 µs)计时,测距本身写成非阻塞状态机(每轮主循环只推进一步),链路收发走中断 + 环形缓冲。任何一次 UART 收发都不会切断计时。

  • 主机侧:完全不接触微秒时序,只管按 5 Hz 收数据、刷 LCD、扫按键。

  • 结果:LCD 刷新、串口打印再慢,也不会让距离读数产生任何抖动。

  顺带还有一个收益:探头可以贴到现场,仪表留在手边。 板间只有一根 UART,线可以拉长——探头贴墙根、塞柜子、装到设备另一侧都行,仪表留在桌上。

3.3 板间链路协议怎么定的

  先说结论:能想到的最土的办法,一个 ASCII 帧加异或校验。

$<body>*HH\r\n

  HH 是 body 里每个字符逐字节异或的结果(不含 $ 和 *),两位十六进制。

fig3_framefmt.png

图 3 帧格式分解。以 $DIST,1,1752*3A\r\n 为例:DIST 是消息类型、1 是从机 ID、1752 是载荷(175.2 cm)、3A 是异或校验

  为什么选 ASCII 而不是二进制:

  1. 串口助手里直接能看懂。调试时不用写解析脚本,肉眼就能看出帧对不对,[TX] $DIST,1,1752*3A 一眼就知道是 175.2 cm。

  2. 校验简单到不可能写错。异或只需要 3 行代码,两端共用同一份 proto.c。

  3. 体积不是问题。一帧 19 字节,115200 波特下约 1.65 ms,而链路只跑 5 Hz,占用率约 0.8%。

  本工程一共用到 6 种帧:

帧方向含义本次实测





$POLL,1*02主机 → 从机查询一次距离19 条,从机全部收到
$DIST,1,1752*3A从机 → 主机上报距离 175.2 cm726 条,主机收 725 条
$SET,1,TH,100*72主机 → 从机设置报警阈值 10.0 cm8 条,从机只收到 6 条
$SET,1,RATE,5*68主机 → 从机设置上报频率 5 Hz(0 = 只应答 POLL)—
$ACK,1,OK*7C从机 → 主机确认执行6 条,主机全收到
$NAK,1,ERR*30从机 → 主机报错(ID 不匹配时用)本版未触发

$SET 那一行 8 条对 6 条不是笔误,是一条真实的缺陷,详见第七节坑 1。

3.4 阈值和上报频率为什么放在从机侧

  看起来有点奇怪:阈值是主机判断的(主机比较距离和阈值,决定亮不亮红灯),为什么还要下发给从机?

  因为从机要按不同模式干活:

  • AUTO 模式:从机自己按 5 Hz 主动上报 $DIST,主机完全不发东西。

  • POLL 模式:从机静默,收到一条 $POLL 才测一次、回一条。

  这两种模式的切换必须让从机知道,所以 $SET,1,RATE,x 是必须的。而 $SET,1,TH,x 是顺手一起下发的——虽然主机自己也有阈值,但让从机也知道阈值,以后可以在从机侧做"超阈值才上报"的降载优化,算是个预留。

四、引脚分配

  这部分我逐条核过,来源写在 board_pins.h 的注释里。引脚复用值取自 SDK 官方例程(LPUART2 在 P3_14/P3_15 上是 Alt2)与 MCXA153 引脚表交叉核对。

4.1 探头板 slave_probe

模块信号MCU 引脚Arduino 排针接到





HC-SR04 VCC—5V模块 VCC
HC-SR04 GND—GND模块 GND(与 MCU 共地)
HC-SR04 TrigP2_4D2模块 Trig(MCU 3.3V 输出可直接驱动)
HC-SR04 EchoP3_1D7模块 Echo 经 10k+10k 分压后
链路 TXP3_15D8→ 主机 P3_14 (D9)
链路 RXP3_14D9← 主机 P3_15 (D8)
共地GNDGND↔ 主机 GND
采样心跳灯P3_13板载绿1 Hz 脉冲(亮 20 ms)
无回波错误灯P3_12板载红4 Hz 脉冲

4.2 仪表板 master_hmi

模块信号MCU 引脚说明




LCD SDAP1_8软件 I2C(开漏 + 内部上拉)
LCD SCLP1_9软件 I2C(开漏 + 内部上拉)
链路 TXP3_15→ 探头板 P3_14
链路 RXP3_14← 探头板 P3_15
KEY1P3_29板载 SW2,切换报警阈值 5 / 10 / 20 cm
KEY2P1_7板载 SW3,切换 AUTO / POLL
报警灯P3_12板载红,2 Hz 脉冲(亮 60 ms)
心跳灯P3_13板载绿,1 Hz 脉冲(亮 20 ms);链路超时则灭
模式灯P3_0板载蓝,AUTO 模式 3 Hz 脉冲;POLL 模式灭

4.3 一个刻意的改动:Trig/Echo 从 P3_15/P3_1 挪到 P2_4/P3_1

  上一篇里 Trig 用的是 P3_15。这一篇必须换掉,因为 P3_15 / P3_14 这一对被 LPUART2 占用了——它们是这条板间链路的物理引脚。

  D8/D9 这两个 Arduino 排针位置正好是 LPUART2 的 TX/RX,接线最方便(并排两根杜邦线),所以让给链路;Trig 挪到 D2(P2_4),Echo 留在 D7(P3_1)。只动了一个脚,Echo 那边的分压电路和代码一行没改。

五、关键代码

5.1 测距为什么用 CTIMER0 而不是 DWT CYCCNT

  上一篇用的是 Cortex-M 内核的 DWT->CYCCNT 周期计数器,读一下就知道过了多少个内核时钟,除以主频就是时间——非常方便,零外设配置。

  但它有一个问题:它属于调试单元。有些调试器/低功耗配置下 DWT 会被关掉,一旦脱离调试器单独跑,计时就不动了。

  这一篇改成 CTIMER0 硬件定时器,预分频到 1 MHz,1 个计数就是 1 µs,脉宽 10160 us 这种日志直接读得出来。好处是:

  • 不依赖调试单元,烧完拔了调试器照样跑。

  • 32 位自由运行计数器,量程约 71 分钟,不存在溢出顾虑。

  • 同一个定时器还能提供 Timebase_Millis(),两端共用一套时基,代码更省。

5.2 非阻塞测距状态机

  测距流程是"Trig 拉高 10 µs → 等 Echo 上升沿 → 计时到 Echo 下降沿"。如果写成阻塞式,主循环会被卡住最多几十毫秒(远距离时),链路和按键都要等。

  所以写成状态机,每轮主循环只推进一步:

typedef enum {
    US_IDLE = 0,      /* 空闲,等下一次触发 */
    US_TRIG_HIGH,     /* Trig 拉高,等 10us */
    US_WAIT_RISE,     /* 等 Echo 上升沿 */
    US_WAIT_FALL,     /* 等 Echo 下降沿,正在计时 */
} us_state_t;
void Ultrasonic_Poll(void)
{
    uint32_t now = Timebase_Micros();
    switch (s_state) {
    case US_IDLE:
        if (now >= s_nextTrigUs) {
            Echo_TimerCapture_Reset();
            TRIG_Write(1);
            s_trigAt = now;
            s_state  = US_TRIG_HIGH;
        }
        break;
    case US_TRIG_HIGH:
        if ((now - s_trigAt) >= 10U) {      /* 10us 触发脉冲够了 */
            TRIG_Write(0);
            s_deadline = now + US_TIMEOUT_US;
            s_state    = US_WAIT_RISE;
        }
        break;
    case US_WAIT_RISE:
        if (Echo_IsHigh()) {
            s_riseUs = now;                  /* 记下上升沿 */
            s_state  = US_WAIT_FALL;
        } else if (now > s_deadline) {
            s_fail++;                        /* 无回波 */
            s_state = US_IDLE;
        }
        break;
    case US_WAIT_FALL:
        if (!Echo_IsHigh()) {
            uint32_t pw = now - s_riseUs;    /* 脉宽,单位 us */
            OnMeasured(pw);                  /* 交给滤波 + 上报 */
            s_state = US_IDLE;
        } else if (now > s_deadline) {
            s_fail++;
            s_state = US_IDLE;
        }
        break;
    }
}

  关键点是没有任何 while 等待。Ultrasonic_Poll() 每次调用最多做一次判断就返回,主循环可以照常处理按键和链路。实测测距速率中位数 55 次/秒——也就是说主循环每秒空转 55 圈,每一圈都在做别的事。

5.3 滤波:3 点中值 + 8 点自适应滑动平均

  两级串起来,各管一件事:

#define DIST_FILTER_WIN      8U     /* 滑动平均窗口长度 */
#define DIST_FILTER_STEP_DM  50U    /* 阶跃判定阈值:5.0cm */
uint32_t DistFilter_Push(uint32_t deciCm)
{
    /* ---- 第一级:3 点中值,专治孤立尖峰 ---- */
    s_raw[s_rawIdx] = deciCm;
    s_rawIdx = (s_rawIdx + 1U) % 3U;
    if (s_rawCnt < 3U) { s_rawCnt++; med = deciCm; }
    else               { med = Median3(s_raw[0], s_raw[1], s_raw[2]); }
    /* ---- 第二级:自适应滑动平均 ---- */
    if (s_hasOut) {
        delta = (med > s_out) ? (med - s_out) : (s_out - med);
        if (delta >= DIST_FILTER_STEP_DM) {
            /* 目标快速移动:窗口整体重置为新值,立即跟上 */
            for (i = 0U; i < DIST_FILTER_WIN; i++) { s_win[i] = med; }
            s_winCnt = DIST_FILTER_WIN;
            s_out    = med;
            return s_out;
        }
    }
    /* 目标基本静止:正常滑动平均,压住抖动 */
    s_win[s_winIdx] = med;
    s_winIdx = (s_winIdx + 1U) % DIST_FILTER_WIN;
    if (s_winCnt < DIST_FILTER_WIN) { s_winCnt++; }
    for (i = 0U; i < s_winCnt; i++) { sum += s_win[i]; }
    s_out    = sum / (uint32_t)s_winCnt;
    s_hasOut = true;
    return s_out;
}

  第一级管"去掉尖峰":取最近 3 个原始值的中位数。孤立的一次异常值必然是中位数以外的那个,直接被丢掉。

  第二级管"又稳又跟得上":8 点滑动平均会把读数压得很稳,但手快速靠近时它会慢半拍。所以加了个自适应:如果新中值和当前输出的差 ≥ 5.0 cm,说明目标在快速移动,直接把整个窗口重置成新值,立刻跟上;否则正常滑动平均。

  本次实测两级的工作量分布(9,520 个有效样本):

滤波行为次数占比




无介入(输出 = 原始)3,58037.6%
微调(差值 ≤ 1 cm)4,80750.5%
平滑(差值 1~5 cm)6306.6%
拒绝离群(差值 > 5 cm)5035.3%

  也就是说:滤波器真"出手拦下"异常值的比例是 5.3%,绝大多数时间它只是轻微平滑。这个比例是合理的——如果拒绝率很高,说明原始信号本身就不可信。

5.4 链路:中断 + 环形缓冲

#define LINK_RX_BUF_SIZE  256U

  收发全走中断,收到的字节丢进环形缓冲,主循环慢慢取。这样 UART 收发永远不会阻塞主循环,也就永远不会切断 CTIMER0 的计时。

  溢出时会 s_rxDropped++ 计数。本次实测一次都没溢出——但余量比想象中小,详见坑 2。

5.5 LED 心跳为什么从"事件驱动"改成"时间驱动"

  最早的心跳灯是"每收到一帧就翻转一次"的事件驱动写法。结果闪频完全由数据速率决定:

  • 主机按 5 Hz 上报的节奏翻转 → 约 2.5 Hz;

  • 从机按约 30 ms 一次的测距节奏翻转 → 约 16 Hz,快到几乎等于常亮,非常刺眼。

  两块板频率对不上,而且从机那盏灯亮得让人难受。这就是我在实测时发现的"两个灯闪烁频率不一样,还太刺眼"。

  改成时间驱动 + 低占空比脉冲之后:亮灭只由 Timebase_Millis() 决定,与数据速率彻底解耦;平均亮度只有常亮的百分之几。

/* master_hmi/source/board_pins.h —— 节奏参数集中在这里 */
#define LED_HEART_PERIOD_MS  1000U   /* 心跳 1 Hz */
#define LED_HEART_ON_MS        20U   /* 亮 20 ms → 占空比 2% */
#define LED_AUTO_PERIOD_MS    333U   /* AUTO 模式 3 Hz */
#define LED_AUTO_ON_MS         12U   /* 亮 12 ms → 占空比 3.6% */
#define LED_ALARM_PERIOD_MS   500U   /* 报警 2 Hz */
#define LED_ALARM_ON_MS        60U   /* 亮 60 ms → 占空比 12% */

  把 onMs 改成 0 就是该灯全灭,调大 onMs 就变亮——调光变成一个数字的事。

  说明一点:两板各自用自己的 Timebase_Millis()(各自复位从 0 开始),所以是同频但不同相位——节奏一致,但谁先亮谁后亮取决于谁先上电。要严格同步的话,可以由主机在开机同步时把当前毫秒数下发给从机做相位对齐。

六、供电:为什么探头板必须插 USB

  这一节单独拎出来,因为它是我这次被问得最多、也最容易想当然的地方。

  结论:默认配置下,FRDM-MCXA153 的唯一供电入口就是 USB。

fig4_power.png

图 4 板载电源链路(依据 NXP UM12012 Table 7)。三个 5V 输入汇到 SYS_5V0,经 LDO U2 生成 3.3V;排针上写着"3V3"的脚全部是这颗 LDO 的输出

6.1 三个输入,其实只有两个能用

输入网络名状态




USB Type-C J8P5V_USB_FS✅ 可用
USB Type-C J15P5V_MCU_LINK_USB✅ 可用
Arduino J3-16P5-9V_VIN❌ 默认断开

  第三条要经过 J22 这颗 5V 稳压器,而 J22 是 DNP(未贴装)。文档原话是:"By default, the option to produce SYS_5V0 supply from P5V_HDR_IN supply is disabled." 所以指望从 VIN 供电也不行。

6.2 排针上所有"3V3"都是输出,不是输入

  这一条特别反直觉,因为排针丝印上明明写着"3V3":

排针实际网络说明




Arduino J3 pin 8LDO_3V3LDO 直接输出
Arduino J2 pin 16VDDA_MCU目标 MCU 模拟电源,从 MCU_VDD_P3V3 经 R66 / JP1 来
mikroBUS J5 +3V3VDD_BOARD板上外设电源(按键、RGB 灯、MCU-Link 3V3)
Arduino J3 pin 10SYS_5V0 (5V)5V,理论上可做输入(但默认没有外部供电路径)

6.3 三条铁律

① 两块板各自插自己的 USB,不要互相供电。 每块板的 3.3V 是用自己的 USB 5V 经板载 LDO U2(XC6227C331PR-G)现场生成的。把两板的 3V3 连起来 = 两颗 LDO 输出直接并联。两者输出电压不可能完全相等(精度 ±1~2%),电压高的一侧会持续往低的一侧灌电流,而 LDO 只能 source、不能 sink——低的那颗输出被顶高,可能损坏 LDO 或 MCU。 ② 板间只连三根线:TX / RX / GND。绝对不要连 3V3。 ③ 探头板的 USB 不能拔。 探头板的调试串口 COMxx 是板上 MCU-Link(LPC55S69)作为 USB-转-UART 桥枚举出来的虚拟串口,它需要 USB 那一路 5V。只从主机灌 3.3V 的话,目标 MCU 也许还能跑,但你在 PC 上看不到任何输出,也没法烧录。

  如果确实想单电源运行(只从主机取电、探头板不插 USB):

  • ❌ 不要从 3.3V 取电。 那是 LDO 输出,反灌属于非设计工况。

  • ✅ 从 5V 侧取电:主机 J3 pin 10(SYS_5V0)或 mikroBUS J5 的 +5V,接到探头板同一位置。此时探头板的 USB 必须拔掉(否则两边的 5V 也会并联冲突)。

  • ⚠️ 代价:探头板就没有 COM 口了,只能靠主机的 LCD 和主机串口间接观察。

  • 更规范的做法:把 J22(DNP)补焊上,再给 J3 pin 16 (VIN) 供 5~9V——这是 NXP 预留的官方外部供电入口。

七、遇到的难点及解决方法

  这一节是全文重点。下面 5 个坑全部是上板实测或串口日志定位出来的,不是推测。 我把定位过程也写出来,因为方法比结论更值钱。

坑 1 · 开机同步的两条 $SET 真的丢了,而且没人知道

  现象:整理日志时发现一个对不上的数字。

主机日志:[TX] $SET 出现 8 次
从机日志:[RX] $SET 出现 6 次
主机日志:[RX] $ACK 出现 6 次

  差的正好是开机同步那两条。主机在 t+44s 打印了 [SYNC] 开机同步探头参数,紧接着发出两条命令:

[t+  44s] [SYNC] 开机同步探头参数(阈值 + 上报频率)
[t+  44s] [TX] $SET,1,TH,100*72   (设置阈值)
[t+  44s] [TX] $SET,1,RATE,5*68   (开启周期上报)

  而从机日志里,t+44s 完全没有这两条帧的痕迹——不是校验错,不是 $NAK,是彻底没出现过。

  定位过程:去查从机在那个时刻在干什么,一下就清楚了。

从机 t+43s: [MEAS] #20752 无有效回波(超时或脉宽超范围),累计失败 195 次
从机 t+44s: [MEAS] #1 脉宽 10160 us -> 原始 175.2 cm -> 滤波 175.2 cm   ← 序号从 1 重新开始

  从机在 t+44s 重启了(序号从 20752 回到 1)。而主机的两条 $SET 恰好也在 t+44s 发出——从机当时正在复位、UART 还没起来,帧就丢在了路上。

  根因:main_hmi.c 里的 [SYNC] 块只发不等 $ACK、也不重传。

/* 主机开机同步:只发一次,不等 ACK、不重传 */
if ((!s_bootSyncDone) && (now > 500U)) {
    PRINTF("\r\n[SYNC] 开机同步探头参数(阈值 + 上报频率)\r\n");
    SendSetTh(s_thresholdCm);      /* 发出去就不管了 */
    SendSetRate(s_rateHz);         /* 发出去就不管了 */
    s_bootSyncDone = true;
}

  为什么这次没出可见故障:因为两边的默认值恰好相同——主机默认阈值 10.0 cm,从机默认也是 10.0 cm;主机默认 5 Hz,从机默认也是 5 Hz。所以同步丢了也看不出来。如果哪次改了从机默认值、或者两边默认值不一致,就会出现"仪表显示 10 cm 阈值、探头按 20 cm 判断"这种极难排查的错位。

  修法(两个方向,建议都做):

方向做法



让发送方负责$SET 发完启动一个 200 ms 超时,收不到 $ACK 就重传,最多 3 次
让接收方负责从机开机后主动上报一次当前配置(加一条 $STAT 帧),主机发现不一致就重发 $SET

  我倾向第二个:从机是"被复位"的那一方,只有它自己知道自己刚重启了。 由它主动"报个到",比让主机猜更可靠。

这个坑之所以值钱,是因为它没有产生任何可见现象。如果不是逐条数帧数,它会一直藏着。日志里 $SET 8 条对 6 条这个差值,是唯一能暴露它的线索。

坑 2 · 主机开机画面阻塞 2 秒,那 11 帧数据去哪了

  现象:上一节说从机在 t+44s 重启,但主机 t+44s 收到的不是一条两条,而是一口气 11 条 $DIST:

[t+  44s] [RX] $DIST,1,582*04     <-- 距离 58.2 cm
[t+  44s] [RX] $DIST,1,574*0D     <-- 距离 57.4 cm
[t+  44s] [RX] $DIST,1,579*00     <-- 距离 57.9 cm
[t+  44s] [RX] $DIST,1,575*0C     <-- 距离 57.5 cm
[t+  44s] [RX] $DIST,1,575*0C     <-- 距离 57.5 cm
[t+  44s] [RX] $DIST,1,569*01     <-- 距离 56.9 cm
[t+  44s] [RX] $DIST,1,585*03     <-- 距离 58.5 cm
[t+  44s] [RX] $DIST,1,583*05     <-- 距离 58.3 cm
[t+  44s] [RX] $DIST,1,584*02     <-- 距离 58.4 cm
[t+  44s] [RX] $DIST,1,579*00     <-- 距离 57.9 cm
[t+  44s] [RX] $DIST,1,544*0E     <-- 距离 54.4 cm

  定位过程:去从机日志里找这 11 个值的来源,发现它们分两秒发出:

从机 t+42s: [TX] $DIST,1,582*04  [TX] $DIST,1,574*0D  [TX] $DIST,1,579*00
            [TX] $DIST,1,575*0C  [TX] $DIST,1,575*0C  [TX] $DIST,1,569*01
从机 t+43s: [TX] $DIST,1,585*03  [TX] $DIST,1,583*05  [TX] $DIST,1,584*02
            [TX] $DIST,1,579*00  [TX] $DIST,1,544*0E

  顺序完全一致、数值逐帧相同,一条不多一条不少。

  根因:主机 t+42s 重启了(日志里能看到开机 banner),而 main_hmi.c 的开机画面有一段阻塞延时:

Lcd_WriteLine(0U, "FRDM-MCXA153");
Lcd_WriteLine(1U, "Rangefinder HMI");
Timebase_DelayMs(2000U);        /* ← 阻塞主循环 2 秒,专门为录像留时间 */

  这 2 秒里主循环完全停住,但 UART 接收中断照常在跑——从机 t+42/43s 发的 11 帧全部被中断收下、塞进了 LINK_RX_BUF_SIZE = 256 的环形缓冲。等 t+44s 主循环恢复,它按原序把这 11 帧一次性取出来打印了。

  顺带算了一笔余量账,结果有点紧:

项值



单帧长度19 字节($DIST,1,1752*3A\r\n)
积压帧数11 帧
占用缓冲11 × 19 = 209 字节
缓冲容量LINK_RX_BUF_SIZE = 256 字节
占用率81.6%
剩余余量约 2.5 帧

  也就是说:如果主机的开机阻塞再长一点、或者从机上报频率调高一点(比如从 5 Hz 调到 10 Hz),这 256 字节的缓冲会在开机那 2 秒里被灌满,后面的帧就开始丢。而 s_rxDropped 这个溢出计数器,全工程没有一个地方去读它(Link_RxDropped() 零调用点)——丢了也不会有人知道。

  旁证:主机的接收计数器在重启后从 0 开始,t+46s 显示 rx=19。19 = 11(积压)+ 8(新收),对得上。

  修法:

  1. 开机画面不要用阻塞延时。改成状态机:显示开机画面 → 记下时间戳 → 主循环照常跑,2 秒后自己切到主界面。

  2. 把 Link_RxDropped() 接到 [STAT] 里打出来。一个不读的溢出计数器等于没有。

坑 3 · 三帧中值挡不住"连续两次一样"的假回波 → 一次真实误报

  这是本次最有价值的一个坑,因为它真的产生了一次误报警,而且录像没拍到。

  现象:t+61s 主机打印了一条报警:

[t+  61s] [RX] $DIST,1,48*37
[t+  61s] [ALARM] 4.8 cm < 阈值 10.0 cm,红灯报警
[t+  62s] [ALARM] 解除

  当时探头前方 1.75 米内什么都没有(前后几秒读数都是 175 cm)。红灯亮了 1 秒。

  定位过程:去从机日志里把这一秒的原始测量全打出来,问题的形状就出来了:

[t+ 61s] [MEAS] #1129 脉宽 10180 us -> 原始 175.5 cm -> 滤波 175.4 cm
[t+ 61s] [MEAS] #1130 脉宽   277 us -> 原始   4.8 cm -> 滤波 175.4 cm   ← 第 1 次,被判离群丢掉
[t+ 61s] [MEAS] #1131 脉宽   277 us -> 原始   4.8 cm -> 滤波   4.8 cm   ← 第 2 次,值一模一样!
[t+ 61s] [TX] $DIST,1,48*37                                             ← 发出去了
[t+ 61s] [MEAS] #1132 无有效回波(超时或脉宽超范围),累计失败 16 次
[t+ 61s] [MEAS] #1133 脉宽 10184 us -> 原始 175.6 cm -> 滤波   4.8 cm   ← 4.8 反而成了"真值"
[t+ 61s] [MEAS] #1134 脉宽 10179 us -> 原始 175.5 cm -> 滤波 175.5 cm   ← 一帧之后跳回来

  根因分两层:

  第一层——277 µs 这个数字太可疑了。 277 µs ÷ 58 ≈ 4.8 cm。而 HC-SR04 自己的发射脉冲就有约 200 µs(8 个 40 kHz 周期)。277 µs 落在"发射脉冲 + 一点点余振"的量级上——接收端把自己发射的拖尾当成了回波。

  第二层——3 点中值只能挡"孤立"的尖峰,挡不住"成对"的尖峰。

med = Median3(s_raw[0], s_raw[1], s_raw[2]);
  • #1130 时,3 个原始值大致是 [175.5, 175.5, 4.8] → 中位数 = 175.5 → 尖峰被挡掉 ✅

  • #1131 时,3 个原始值变成 [175.5, 4.8, 4.8] → 中位数 = 4.8 → 尖峰穿过去了 ❌

  中位数只要出现两次相同的值,就会成为中位数。而更糟的是第二级紧接着"帮了倒忙":

if (delta >= DIST_FILTER_STEP_DM) {   /* |4.8 - 175.4| = 170.6 >= 5.0 */
    /* 目标快速移动:窗口整体重置为新值,立即跟上 */
    for (i = 0; i < DIST_FILTER_WIN; i++) { s_win[i] = med; }
    s_out = med;                       /* ← 立刻输出 4.8 */
}

  第二级的"快速跟随"是为手快速靠近设计的——差值超过 5 cm 就认为目标在移动,立刻跟上。这个设计本身没错,但它无法区分"目标真的在快速移动"和"假回波成对出现"。于是 4.8 cm 被当成"目标快速移动到了 4.8 cm",直接输出并上报。

  这类假回波有多常见? 我把整段日志统计了一遍:

统计项数值



有效测距样本9,520
脉宽 < 600 µs 的杂散1,324 次(13.9%)
其中"与上一次原始值完全相同"(能击穿中值)980 次

  980 次——也就是说,这条链路上"能击穿 3 点中值"的成对杂散出现了 980 次。绝大多数被后面的数据拉回来了(只影响一两帧),但 t+61s 那一次正好赶上主机也在按 5 Hz 采、并且阈值判定在 10 cm,就变成了一次真实的红灯误报。

  修法(三层,从便宜到彻底):

层做法成本




① 物理在 Echo 输入端对地加 100 pF 电容,或在软件里丢弃脉宽 < 400 µs 的样本(低于 HC-SR04 的物理最小量程)一行代码
② 算法把"3 点中值"升级成"5 点中值"——需要连续 3 次相同的假值才能击穿,而 277 µs 的拖尾很难连出 3 个完全相同的值改一个常量
③ 逻辑报警加确认:连续 2 次上报都低于阈值才点亮红灯(去抖),而不是一帧就报警加一个计数器

  我这次在报告里把三条都写出来,但没有回头改固件重新录一遍——因为这一版的数据(包括这次误报)本身就是一个有价值的实测结果,改掉反而少了一个证据。

顺便说一句:这次误报录像完全没拍到。因为它只持续 1 秒,而我的抽帧是每 2 秒一帧,正好跳过去了。只有串口日志能证明它发生过。 这也是我建议"录像 + 串口日志同时抓"的原因。

坑 4 · 两盏灯闪烁频率不一样,还刺眼

  现象:两块板都跑起来之后,第一眼看到的问题不是距离不准,而是两盏灯闪得不一样快,而且太亮刺眼。

  定位过程:把两端 board_pins.h 里的 LED 逻辑摊开对比,发现两边都是"事件驱动"写法——每收到/发出一帧就翻转一次 LED。于是闪频直接等于数据速率:

板触发源触发速率实际闪频





仪表板收到一帧 $DIST5 Hz约 2.5 Hz
探头板完成一次测距约 30 ms 一次约 16 Hz

  探头板那盏灯 16 Hz 闪烁,人眼看上去就是常亮,而且 RGB 灯亮度本来就高,非常刺眼。

  根因:把"数据速率"和"人机指示"耦合在了一起。 数据速率是系统内部的事,用户不需要知道,更不该用它来驱动指示灯。

  修法:改成时间驱动 + 低占空比脉冲——亮灭只由 Timebase_Millis() 决定,与数据速率彻底解耦。同时把占空比压到百分之几:

灯周期亮的时间占空比





心跳(绿)1000 ms(1 Hz)20 ms2.0%
AUTO 模式(蓝)333 ms(3 Hz)12 ms3.6%
报警(红)500 ms(2 Hz)60 ms12%

  改完之后两盏灯同频(都是 1 Hz 心跳),平均亮度只有常亮的百分之几,不刺眼了。

  一个副产品:因为参数全在 board_pins.h 里集中定义,把 onMs 改成 0 就是该灯全灭,调大就变亮——"调光"变成改一个数字的事,不用碰逻辑。

  剩下的一个小遗憾:两板各自用自己的 Timebase_Millis()(各自复位从 0 开始),所以是同频但不同相位——谁先亮谁后亮取决于谁先上电。要严格同步,可以由主机在开机同步时把当前毫秒数下发给从机做相位对齐(协议加一条 $SET 就行,但本次没做)。

坑 5 · Echo 的 5V 和 LCD 背包的 5V 上拉——两个差点烧 IO 的地方

  这两个不属于"调试踩坑",属于接线前就该避开的电气陷阱,但都很容易想当然,所以一并写出来。

  ① HC-SR04 的 Echo 是 5V TTL。 MCXA153 的 IO 不是 5V 耐受。我一开始想直接接,查了一下 MCXA153 数据手册的 IO 绝对最大额定值就停手了——必须分压。

  ② PCF8574 背包的 SDA/SCL 通常各有一个 10k 上拉到 VCC。 如果背包用 5V 供电,那么总线空闲时 SDA/SCL 会被拉到 5V——即使 MCU 侧输出 3.3V,空闲电平还是 5V。这一条比 Echo 那条更隐蔽,因为它不是"某个引脚接错",而是"整个总线在空闲时就是 5V"。

  处理方式(三选一,本工程按 B 调优):

方案做法评价




A(最稳)背包 5V 供电 + SDA/SCL 各串双向电平转换对比度好,电平规范
B(推荐)背包 5V 供电,拆掉背包上那两个 10k 上拉,改用 MCU 内部上拉(3.3V)最省事,本工程默认配置
C(最简单)背包直接 3.3V 供电无需改动,但对比度可能偏淡

  一个通用经验:拿到任何一个"5V 时代的模块",第一件事不是接线上电,而是确认它的输出电平和上拉电压。这两条如果漏了,坏的不是代码,是芯片。

八、实测数据与照片

  以下所有数据来自同一段 143 秒的实机演示:两块板各自插 USB,主机串口(COM15)与从机串口(COM24)同时抓取,录像同步进行。

8.1 编译与静态验证

验证项方式结果




两个 MCU 工程可编译链接MCUXpresso IDE 自带 arm-none-eabi-gcc 14.2.1 + redlib,make all✅ 零告警
从机代码体积slave_probe/Debug/build.log23,384 B Flash(17.84%)/ 4,176 B RAM(16.99%)
主机代码体积master_hmi/Debug/build.log26,048 B Flash(19.87%)/ 4,176 B RAM(16.99%)
协议组帧 / 解帧 / 校验tools/host_test_proto.c,PC 上真实运行✅ 52 项全部通过(含校验错、超长帧、噪声、往返一致等边界)
PC 端协议理解一致tools/pc_monitor.py --selftest✅ 27 项全部通过
LCD 三种后端可编译分别以 LCD_BUS=0/1/2 编译 lcd.c✅ 三种均编译通过
一键编译脚本bash tools/build.sh,先 clean 再全量重建✅ 两个工程从零编译通过

8.2 协议量化指标

  整段抓包里所有帧的异或校验,逐帧重算:

项数值



抓到的帧总数(两份日志合计)1,515
异或校验通过1,515
校验失败0

  按类型分:

帧类型合计拆分




$DIST1,451从机发 726 + 主机收 725
$POLL38主机发 19 + 从机收 19
$SET14主机发 8 + 从机收 6 ← 差 2,即坑 1
$ACK12从机发 6 + 主机收 6

  其他运行指标:

指标数值



测距速率(稳态中位数)55 次/秒(全程 9,520 次有效测距 / 145 s)
上报速率(AUTO 模式)5.0 Hz(726 帧 / 145 s)
POLL 模式实际速率t+78~81 共 4 秒发 19 条 $POLL,约 4.8 Hz
无有效回波113 次 / 9,633 次 = 1.17%
滤波拒绝离群(差值 > 5 cm)503 次 = 5.3%
链路校验错 / $NAK / 溢出0 / 0 / 0

  稳态读数精度(探头对着 1.75 m 外的固定目标,手不在场):

项数值



主区间175.1 ~ 175.5 cm
出现最多的值175.1 cm(2,164 次)
全部稳态取值172.3 ~ 178.1 cm(含少量多径反射)

8.3 画面与日志逐帧对照

  录像和日志是两套独立的时间轴,我用两个独立锚点把它们对齐了:开机画面出现的那一刻(录像 t=8 s ↔ 日志 t+42 s),以及手停在 51 cm 那一刻(录像 t=18 s 画面读数 51.3 ↔ 日志 t+52 s 记录 51.4)。两个锚点解出同一个偏移:日志时间 = 录像时间 + 34 秒。

  对齐之后逐帧核对,画面读数与日志数值吻合到 1 个最小刻度(0.1 cm)。

fig5_boot.jpg

图 5 开机画面(整帧)。画面左上方那块 LCD1602 上的两行是 FRDM-MCXA153 / Rangefinder HMI。这段画面停留 2 秒,就是坑 2 里那段阻塞延时——它挡住了主循环,但没挡住 UART 中断

fig6_key2.jpg

图 6 按下 KEY2 切到 POLL 模式的那一刻。画面读数 D=175.1cm、TH=10.0cm POLL,与日志 t+78s 的 模式=POLL 距离=175.1 cm 阈值=10.0 cm 完全一致

fig7_lcd_51cm.jpg

图 7 手停在 51 cm 处,LCD 显示 D=51.3cm OK / TH=10.0cm AUTO;同一时刻从机日志是 原始 51.4 cm -> 滤波 51.3 cm

fig8_alarm.jpg

图 8 手贴近探头(整帧)。LCD 显示 D=2.8cm ALARM / TH=5.0cm AUTO。这次报警从日志 t+89s 的 [ALARM] 4.6 cm < 阈值 5.0 cm 开始,持续到 t+94s 解除;照片是 t+90s 那一刻,手比刚触发时又近了一点,读数掉到 2.8 cm

fig9_lcd_175cm.jpg

图 9 手移开后回到稳态,LCD 显示 D=175.5cm OK / TH=20.0cm AUTO

fig10_led.jpg

图 10 仪表板(主机板)1:1 实拍,没有做任何放大——放大只是插值,只会把像素糊掉。能看清这是 NXP FRDM-MCXA153:左侧两个 USB 口分别接调试器和目标 MCU,右侧两列排针,板子正上方那块绿色模块就是 LCD1602 + PCF8574 背包。至于板载 RGB 用户灯(P3_12 红 / P3_13 绿 / P3_0 蓝),固件把它从"常亮"改成了"每周期只亮 12~60 ms"的低占空比脉冲,平均亮度压到 2%~12%;但这个改动在静态照片里看不出来,它属于坑 4,只能靠代码和现场观感说明

fig11_terminal.jpg

图 11 PC 上同时开着两个串口窗口:左边主机 COM15,右边从机 COM24。两边的时间戳前缀 [t+ xx s] 是同一次抓取打的,所以可以逐行对照

8.4 报警事件全表

  整段 143 秒里,报警一共触发 6 次(其中 1 次是误报):

日志 t+录像 t触发值当时阈值解除判定







0 s—5.5 cm10.0 cm0 s录像开始前的残留
61 s27 s4.8 cm10.0 cm62 s❌ 误报(坑 3,277 µs 假回波)
89 s55 s4.6 cm5.0 cm94 s✅ 手真的贴近了
99 s65 s9.2 cm10.0 cm104 s✅ 手移近
112 s78 s9.8 cm20.0 cm118 s✅ 阈值已切到 20 cm
122 s88 s16.0 cm20.0 cm124 s✅ 手在 16 cm 处

  按键一共按了 6 次:KEY2 两次(t+77 切 POLL、t+81 切回 AUTO),KEY1 四次(t+84 → 20 cm、t+86 → 5 cm、t+97 → 10 cm、t+110 → 20 cm)。每一次按键,主机日志里都能看到 [TX] $SET 紧跟一条 [RX] $ACK——按键这条链路的 6 次 $SET 全部收到确认,一次不丢。丢的只有坑 1 里那两条开机同步。

8.5 143 秒事件时间线

fig12_timeline.png

图 12 143 秒实测事件时间线。上方三条带分别是工作模式、报警阈值、报警状态;中间的 8 个编号圆点对应下方图例里的事件;最下面是时间轴(串口日志时间,等于录像时间 + 34 秒)

九、心得体会与建议

9.1 关于 FRDM-MCXA153 这块板子

  好的地方:

  • 调试器和目标 MCU 分离是这块板最大的优点。SW1 只复位目标 MCU,MCU-Link 不受影响,所以按复位键串口不会掉——这次能抓到"开机同步丢帧"这个坑,全靠这个特性。

  • 板载调试串口(VCOM)省事。不用外接 USB-TTL,插上就能看日志,两块板同时抓互不干扰。

  • Arduino 排针位置排得讲究。D8/D9 正好是 LPUART2 的 TX/RX,板间链路两根并排杜邦线就搞定。

  • 128 KB Flash 对这个项目绰绰有余(两个工程都只用了不到 20%),有大量空间可以加功能。

  要留神的地方:

  • 供电入口比想象中少。三个 5V 输入里,Arduino VIN 那条因为 J22 是 DNP 而默认断开;排针上所有"3V3"都是 LDO 输出。想单电源运行只能从 5V 侧取电,或者补焊 J22。这一点文档里有,但藏在 Table 7 和 §2.10 里,不查资料很容易想当然。

  • 排针丝印"3V3"有误导性。它长得像"给你供电的脚",实际是"这颗 LDO 的输出"。

  • RGB 用户灯太亮。默认常亮或者高占空比闪烁都刺眼,建议任何项目都从一开始就用低占空比脉冲。

9.2 关于"双板"这个方案本身

  做下来我觉得双板的最大价值不是"距离能拉远",而是"故障域被切开了"。

  单板方案里,测距、显示、按键、串口打印全在一块芯片上,任何一个环节卡住都会互相影响。双板之后:

  • 探头板的代码只有 5 个模块(ultrasonic / filter / proto / link_uart / timebase),逻辑简单到几乎不可能出问题;

  • 仪表板完全不碰微秒时序,它卡住不会让距离读数变抖;

  • 排查问题时,两边的串口日志一对照,问题出在哪一侧一眼就能定位。

  这次 5 个坑里有 3 个(坑 1、2、3)都是靠"两边日志逐行对照"发现的——这在单板方案里根本做不到。

  代价也要说清楚:多了一块板、多了一条可能出问题的链路、多了一份要同步的配置。如果项目本身不需要探头远离仪表,单板方案更简单,没必要为了双板而双板。

9.3 关于我自己

  这次最大的收获是学会了"数帧"这种笨办法。

  一开始我也是看现象:距离准不准、灯亮不亮、LCD 显示对不对。这些都对,看起来一切正常。后来强迫自己把两份日志里每一种帧数一遍,$SET 8 条对 6 条这个差值才跳出来——一个完全没有可见症状的缺陷。

  第二个收获是理解了"日志比录像更可靠"。坑 3 那次误报只持续 1 秒,2 秒抽帧的录像正好跳过去了。如果我只看录像,会得出"这一版运行完全正常"的结论——而实际上它误报过一次。录像能证明"看得见的现象确实发生过",但它不能证明"没发生过什么"。

  第三个收获是对"容错设计"有了新的认识。3 点中值 + 自适应平均这两级滤波器,单看每一级的设计都是有道理的:中值去尖峰、自适应跟快速移动。但两级串起来之后,第二级的"快速跟随"恰好成了第一级漏网之鱼的放大器——中值一旦被成对尖峰击穿,第二级会立刻把它当成"目标快速移动"接受下来。分立的、各自合理的设计,组合起来可能产生谁都没预料到的行为。 这个教训比具体那个 bug 更值钱。

9.4 给后来者的四条建议

  1. 两份日志都抓,然后数帧。 不要只看"现象对不对"。两端日志里每一种帧的数量必须能对上,对不上就是有缺陷——哪怕它没有任何可见症状。

  2. 5V 时代的模块,接线前先确认输出电平和上拉电压。 HC-SR04 的 Echo、PCF8574 背包的 SDA/SCL 上拉,都是"不查资料就会想当然"的地方。坏的不是代码,是芯片。

  3. 不要把"数据速率"耦合进人机指示。 LED 闪烁、蜂鸣器节奏这类东西,一律用时间驱动 + 低占空比,让它们跟数据速率彻底解耦。

  4. 每一个"容错"设计都要问一句:什么情况下它会失效? 3 点中值挡不住成对尖峰、开机同步没重传、环形缓冲的溢出计数器没人读——这三个缺陷的共同点是它们都是"为了稳妥而加的设计",但都没定义自己的失效边界。

源代码:Dual_FRDM_MCXA153.zip




关键词: let's do     FRDM-MCXA153     超声波    

专家
2026-09-28 08:39:08     打赏
2楼

谢谢楼主分享


共2条 1/1 1 跳转至页

回复

匿名不能发帖!请先 [ 登陆 注册 ]