一、需求不复杂,先把路摸清楚
我申请时候的计划中写了:拧板上的旋钮,RGB LED 的白光亮度跟着平滑变化——台灯的最简形态。
动手前先对着原理图把通路捋了一遍:
环节连接说明
| 输入 | 电位器 → RA7/AN6 | 12 位 ADC(ADC1 通道 0) |
| 处理 | dsPIC33AK128MC106 | EMA 滤波 + 占空比映射 |
| 输出 | RC2/RD0/RD2 → RGB LED | 三路 SCCP PWM,周期 65535 |
【配图 1】原理图相关部分截图:RGB LED 与电位器接线(DS70005562 附录 A)

软件这边,我没有从零配外设,而是拿官方 OOB 演示的 MCC 配置改的(时钟、ADC、PWM、引脚初始化都是 MCC 生成的,靠谱),应用层自己写。核心逻辑就一个循环:
uint16_t sample = readPotentiometer(); // ADC 采样(软件触发 + 轮询) filtered = emaFilter(sample); // EMA 指数滑动平均,Q8 定点 uint32_t duty = mapToDuty(filtered); // 12 位 → 16 位占空比 SCCP1_PWM_DutyCycleSet(duty); // 红 SCCP2_PWM_DutyCycleSet(duty); // 绿 SCCP3_PWM_DutyCycleSet(duty); // 蓝
思路是不是很顺?烧进去一看——翻车了。
二、Bug 现场:「能调,但基本看不出变化」
第一版烧上去,旋钮从头拧到尾,亮度就轻轻变了一点。不对劲啊,ADC 是 12 位、PWM 是 16 位,动态范围绰绰有余,怎么会这样?
这种时候,继续盯着代码瞎猜是最低效的路子。我决定先让程序自己开口说话:加串口输出(板子上自带 MCP2221A USB 转串口,9600 8-N-1,Mac 上 stty 设好波特率直接 cat 设备文件就能看到),每轮把 adc / filtered / duty 三个值打出来。
【配图 2】串口输出「案发现场」:adc 只有个位数,filtered 却反复爆到 1677 万

数据一出来,案子就破了。这是旋钮拧在最小位附近的实录——adc 只有个位数的微小抖动,filtered 却反复炸到 1677 万:
adc=11 filtered=16777205 duty=1048400 ← filtered 爆到 1677 万,duty 越界 16 倍! adc=7 filtered=16777081 duty=1046416 adc=8 filtered=2 duty=32 ← 偶尔"正常"一行 adc=5 filtered=16746964 duty=564544 adc=9 filtered=16552555 duty=588656
看见没,adc 明明稳在个位数,filtered 却时不时窜到 1600 多万,再慢慢爬回来——滤波器自己在抽风。哪怕只有 ±2 的向下抖动,都够触发一次爆炸。
三、根因:一个无符号减法,把滤波器送上了天
问题就出在这一行(Q8 定点 EMA,y += (x - y) / 32):
// 错误版本:uint32_t 运算 filtered = filtered + ((((uint32_t)sample << 8) - filtered) >> 5);
坑在哪?无符号减法下溢。当你把旋钮往低处转,目标值 sample << 8 会小于当前的 filtered,这时候 (sample << 8) - filtered 在 uint32_t 的世界里不是个负数——而是下溢成一个接近 2³² 的巨型正数。右移 5 位依然巨大,加回去,filtered 当场起飞。然后滤波值慢慢"爬"回正常区间,直到下一次下溢,再炸。数据里那种"爆炸—回落—再爆炸"的振荡形态,就是这么来的。
典型的"这行代码看着挺对"系列。C 语言里无符号运算的坑,教科书都写烂了,真栽进去的时候还是调了半天。
数据里还顺带暴露了另一个小问题:adc 偶尔会读到 65535(0xFFFF)。轮询读 ADC 时偶尔会采到无效值,单看不算大事(滤波器真正的元凶是下溢),但也顺手治了——超量程的采样直接丢掉,沿用上一次有效值。
修复就两行的事:
// 正确版本:有符号差值 int32_t delta = ((int32_t)sample << 8) - (int32_t)filtered; filtered = (uint32_t)((int32_t)filtered + (delta >> EMA_SHIFT));
四、修好之后,再抠两个细节
修完再烧,旋钮全程的亮度变化已经相当顺滑了。不过拧到最左的时候,LED 还剩一点微光,不灭。翻底板原理图找到原因:电位器两端各串了一个 20Ω 的保护电阻,ADC 最低只能读到 5 上下(到不了真正的 0),映射出来占空比约 0.12%——LED 就是 faintly 亮着。
台灯嘛,拧到底就该灭,这才符合直觉。于是加了个死区:滤波值低于 40(大约 1% 量程)直接输出占空比 0。这个数也不能太大,不然"刚拧一点灯就该亮"的响应就没了。
采样和映射的完整代码贴出来:
#define ADC_MAX 4095u /* 12 位 ADC 满量程 */
#define ADC_DEAD_ZONE 40u /* 死区:低于此值视为 0,让 LED 彻底熄灭 */
static uint16_t lastValidSample = 0; /* 上一次有效采样 */
/* ADC 采样:软件触发 + 轮询等待,顺手丢掉偶发的 0xFFFF 无效值 */
static uint16_t readPotentiometer(void)
{
uint32_t raw;
ADC1.SoftwareTriggerEnable();
while (!ADC1.IsConversionComplete(ADC1_Channel0)) { }
raw = ADC1.ConversionResultGet(ADC1_Channel0);
if (raw > ADC_MAX) {
return lastValidSample; /* 毛刺:沿用上一次有效值 */
}
lastValidSample = (uint16_t)raw;
return lastValidSample;
}
/* 死区 + 线性映射:12 位 ADC → 16 位 PWM 占空比 */
static uint32_t mapToDuty(uint32_t level)
{
if (level < ADC_DEAD_ZONE) {
return 0u; /* 死区内彻底熄灭 */
}
return (level * (PWM_PERIOD + 1u)) / (ADC_MAX + 1u);
}两个调参的参考值,省得你再摸一遍:
EMA_SHIFT = 5(也就是 1/32):消抖效果和响应速度的平衡点。太小,灯跟着旋钮抖;太大,灯"追不上手"
ADC_DEAD_ZONE = 40:底板保护电阻决定的下限之上,留点余量
最后把这次调试的功臣也亮个相——串口工具函数。没有 printf 重定向,就直接拿寄存器级的 UART 驱动手搓了一套(9600 8-N-1 对着板载 MCP2221A):
static void uartPutc(char c) { UART1_Write((uint8_t)c); }
static void uartPutU32(uint32_t v) /* 无 printf 环境下的整数打印 */
{
char buf[10]; uint8_t i = 0;
if (v == 0) { uartPutc('0'); return; }
while (v > 0 && i < sizeof(buf)) { buf[i++] = (char)('0' + (v % 10u)); v /= 10u; }
while (i > 0) { uartPutc(buf[--i]); }
}
/* 用法:每 N 轮主循环打印一次,别把 9600 波特率打爆了 */
uartPuts("adc="); uartPutU32(sample);
uartPuts(" filtered="); uartPutU32(filtered >> 8);
uartPuts(" duty="); uartPutU32(duty);
uartPuts("\r\n");修复 + 死区之后,再来一次旋钮全程扫频,数据干干净净:
【配图 3】修复后的串口数据:旋钮 min→max→min 全程,filtered 平滑跟随

【配图 4】调光效果三连拍:最左(灭)→ 中间 → 最右(最亮)



五、这次调试的心得
回头看,这次排障最值得记下来的不是 bug 本身,而是路径:没有串口打印之前,看哪儿都像有问题;有了数据之后,几分钟就锁定了真凶。 嵌入式调试里,"让程序告诉你它看到的世界"永远比"盯着代码硬想"高效。一个 9600 的串口 + stty + cat,就是 Mac 上零成本的调试终端。
我要赚赏金
