一、简介
两块 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。

图 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_1 | P2_4 (D2) / P3_1 (D7) | D8/D9 让给板间链路 |
| LCD 驱动 | 软件 I2C(P1_8/P1_9) | 同样是软件 I2C(P1_8/P1_9) | 复用已经验证过的接线,零改动 |
| 计时方式 | DWT CYCCNT | CTIMER0 硬件定时器 1 MHz | 不依赖调试单元,脱离调试器也稳定 |
| 滤波 | 10 点滑动平均 + 5 cm 阶跃重置 | 3 点中值 + 8 点自适应滑动平均 | 先去掉尖峰,再平滑 |
二、硬件介绍
2.1 FRDM-MCXA153(用了两块)
项目参数
| MCU | MCXA153VLH,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)。
它有两个必须注意的地方:
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 分压电阻并联,把分压比拉偏。
它的发射脉冲本身就有 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 系统框图

图 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 里每个字符逐字节异或的结果(不含 $ 和 *),两位十六进制。

图 3 帧格式分解。以 $DIST,1,1752*3A\r\n 为例:DIST 是消息类型、1 是从机 ID、1752 是载荷(175.2 cm)、3A 是异或校验
为什么选 ASCII 而不是二进制:
串口助手里直接能看懂。调试时不用写解析脚本,肉眼就能看出帧对不对,[TX] $DIST,1,1752*3A 一眼就知道是 175.2 cm。
校验简单到不可能写错。异或只需要 3 行代码,两端共用同一份 proto.c。
体积不是问题。一帧 19 字节,115200 波特下约 1.65 ms,而链路只跑 5 Hz,占用率约 0.8%。
本工程一共用到 6 种帧:
帧方向含义本次实测
| $POLL,1*02 | 主机 → 从机 | 查询一次距离 | 19 条,从机全部收到 |
| $DIST,1,1752*3A | 从机 → 主机 | 上报距离 175.2 cm | 726 条,主机收 725 条 |
| $SET,1,TH,100*72 | 主机 → 从机 | 设置报警阈值 10.0 cm | 8 条,从机只收到 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 Trig | P2_4 | D2 | 模块 Trig(MCU 3.3V 输出可直接驱动) |
| HC-SR04 Echo | P3_1 | D7 | 模块 Echo 经 10k+10k 分压后 |
| 链路 TX | P3_15 | D8 | → 主机 P3_14 (D9) |
| 链路 RX | P3_14 | D9 | ← 主机 P3_15 (D8) |
| 共地 | GND | GND | ↔ 主机 GND |
| 采样心跳灯 | P3_13 | 板载绿 | 1 Hz 脉冲(亮 20 ms) |
| 无回波错误灯 | P3_12 | 板载红 | 4 Hz 脉冲 |
4.2 仪表板 master_hmi
模块信号MCU 引脚说明
| LCD SDA | P1_8 | 软件 I2C(开漏 + 内部上拉) |
| LCD SCL | P1_9 | 软件 I2C(开漏 + 内部上拉) |
| 链路 TX | P3_15 | → 探头板 P3_14 |
| 链路 RX | P3_14 | ← 探头板 P3_15 |
| KEY1 | P3_29 | 板载 SW2,切换报警阈值 5 / 10 / 20 cm |
| KEY2 | P1_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,580 | 37.6% |
| 微调(差值 ≤ 1 cm) | 4,807 | 50.5% |
| 平滑(差值 1~5 cm) | 630 | 6.6% |
| 拒绝离群(差值 > 5 cm) | 503 | 5.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。

图 4 板载电源链路(依据 NXP UM12012 Table 7)。三个 5V 输入汇到 SYS_5V0,经 LDO U2 生成 3.3V;排针上写着"3V3"的脚全部是这颗 LDO 的输出
6.1 三个输入,其实只有两个能用
输入网络名状态
| USB Type-C J8 | P5V_USB_FS | ✅ 可用 |
| USB Type-C J15 | P5V_MCU_LINK_USB | ✅ 可用 |
| Arduino J3-16 | P5-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 8 | LDO_3V3 | LDO 直接输出 |
| Arduino J2 pin 16 | VDDA_MCU | 目标 MCU 模拟电源,从 MCU_VDD_P3V3 经 R66 / JP1 来 |
| mikroBUS J5 +3V3 | VDD_BOARD | 板上外设电源(按键、RGB 灯、MCU-Link 3V3) |
| Arduino J3 pin 10 | SYS_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(新收),对得上。
修法:
开机画面不要用阻塞延时。改成状态机:显示开机画面 → 记下时间戳 → 主循环照常跑,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。于是闪频直接等于数据速率:
板触发源触发速率实际闪频
| 仪表板 | 收到一帧 $DIST | 5 Hz | 约 2.5 Hz |
| 探头板 | 完成一次测距 | 约 30 ms 一次 | 约 16 Hz |
探头板那盏灯 16 Hz 闪烁,人眼看上去就是常亮,而且 RGB 灯亮度本来就高,非常刺眼。
根因:把"数据速率"和"人机指示"耦合在了一起。 数据速率是系统内部的事,用户不需要知道,更不该用它来驱动指示灯。
修法:改成时间驱动 + 低占空比脉冲——亮灭只由 Timebase_Millis() 决定,与数据速率彻底解耦。同时把占空比压到百分之几:
灯周期亮的时间占空比
| 心跳(绿) | 1000 ms(1 Hz) | 20 ms | 2.0% |
| AUTO 模式(蓝) | 333 ms(3 Hz) | 12 ms | 3.6% |
| 报警(红) | 500 ms(2 Hz) | 60 ms | 12% |
改完之后两盏灯同频(都是 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.log | 23,384 B Flash(17.84%)/ 4,176 B RAM(16.99%) |
| 主机代码体积 | master_hmi/Debug/build.log | 26,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 |
按类型分:
帧类型合计拆分
| $DIST | 1,451 | 从机发 726 + 主机收 725 |
| $POLL | 38 | 主机发 19 + 从机收 19 |
| $SET | 14 | 主机发 8 + 从机收 6 ← 差 2,即坑 1 |
| $ACK | 12 | 从机发 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)。

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

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

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

图 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

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

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

图 11 PC 上同时开着两个串口窗口:左边主机 COM15,右边从机 COM24。两边的时间戳前缀 [t+ xx s] 是同一次抓取打的,所以可以逐行对照
8.4 报警事件全表
整段 143 秒里,报警一共触发 6 次(其中 1 次是误报):
日志 t+录像 t触发值当时阈值解除判定
| 0 s | — | 5.5 cm | 10.0 cm | 0 s | 录像开始前的残留 |
| 61 s | 27 s | 4.8 cm | 10.0 cm | 62 s | ❌ 误报(坑 3,277 µs 假回波) |
| 89 s | 55 s | 4.6 cm | 5.0 cm | 94 s | ✅ 手真的贴近了 |
| 99 s | 65 s | 9.2 cm | 10.0 cm | 104 s | ✅ 手移近 |
| 112 s | 78 s | 9.8 cm | 20.0 cm | 118 s | ✅ 阈值已切到 20 cm |
| 122 s | 88 s | 16.0 cm | 20.0 cm | 124 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 秒事件时间线

图 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 给后来者的四条建议
两份日志都抓,然后数帧。 不要只看"现象对不对"。两端日志里每一种帧的数量必须能对上,对不上就是有缺陷——哪怕它没有任何可见症状。
5V 时代的模块,接线前先确认输出电平和上拉电压。 HC-SR04 的 Echo、PCF8574 背包的 SDA/SCL 上拉,都是"不查资料就会想当然"的地方。坏的不是代码,是芯片。
不要把"数据速率"耦合进人机指示。 LED 闪烁、蜂鸣器节奏这类东西,一律用时间驱动 + 低占空比,让它们跟数据速率彻底解耦。
每一个"容错"设计都要问一句:什么情况下它会失效? 3 点中值挡不住成对尖峰、开机同步没重传、环形缓冲的溢出计数器没人读——这三个缺陷的共同点是它们都是"为了稳妥而加的设计",但都没定义自己的失效边界。
我要赚赏金
