任务介绍
- 用8颗单色 LED 与 2 颗 WS2812 实现至少 4 种光效:呼吸、频闪、流水、随机闪烁
- 4 个按键用于光效切换、速度加减、亮度加减;4 个拨码开关用于选择速度档位
- 双位七段数码管显示当前光效编号与速度档位
- USB 虚拟串口输出当前光效参数
硬件平台
- RP2350B核心板,拥有12M晶振,2M flash,12位8通道 ADC,还有独特的PIO设计,适合 ws2812对时序要求高的模块
- 双位七段数码管,配合两颗 74HC595,可以实现仅3个 IO口(SER、RCK、SCK)驱动数码管
- 8颗单色 LED,由4个 IO口控制
- 4个按键和4个拨码开关,采用分压网络设计,每个按键按下都有对应的电压值,可以实现1个ADC口识别8个按键
- 两颗 WS2812,串行连接,由一个 IO口进行控制
开发平台
本项目基于 VSCode的 Raspberry Pi Pico平台进行开发,内置了本项目所需的例程,如 pio_ws2812,
blank,adc_dma_capture等,可以直接参考使用。

任务分析
本项目需要实现四种灯效的显示和控制。同时由于硬件设计特殊,IO口精简,对各个模块之间配合要求较高,需要
合理设计架构。根据任务要求可以分为四个小模块:led控制、按键识别、数码管显示、ws2812显示和一个集合各个模块的综合性任务。
- ws2812: ws2812可以直接使用官方的例程,只需要修改 IO口为46
- LED:根据原理图,led的四个 IO口需要两个为高阻态(输入)、一个高电平和一个低电平才能点亮一个led,这四种状态排列组合一共有12种情况。所以可以遍历每种情况,得到 led与 IO口的状态对应表。在此基础上进行复杂灯效的开发。
- 数码管:数码管的74HC595位移寄存器的工作原理为串行输入、并行输出。例如以串行的方式输入 00111111,则有8个 IO口输出为对应的高低电平,刚好可以用于数码管的段码控制,显示为0。如果输入超过8位,则先输入的数据可以通过溢出引脚,进入下一个位移寄存器,这第二个位移寄存器可以控制数码管的位选开关。通过快速刷新数码管,就可以实现个位和十位的同时显示。并且位移寄存器具有锁存功能,不需要一直发送显示数据,减轻主控任务量和调度复杂度。
- 按键:因为硬件差异,识别按键最好的方式是先实测各按键的电压值,再进行识别。由于按键分压差距小,并且有电容等其它因素干扰,需要进行滤波处理。按键识别需要相同的滤波方式,并且要避免电容充放电和机械开关的干扰,还需加入抖动延时。
功能实现
本项目整体流程设计以及各个模块任务如图所示:

一、ws2812
ws2812可以使用官方的例程,但需要修改灯珠数量和 IO46的 pio初始化
在CMakeLists.txt中加入
target_compile_definitions(led_effect PRIVATE
PICO_PIO_USE_GPIO_BASE=1
)
并且设置
static PIO ws2812_pio_main = pio2;
pio_set_gpio_base(ws2812_pio_main, 16);
二、LED
通过实测可以得到 led的对应 IO状态,如下所示,led顺序按照核心板的实际摆放位置排列
static const uint8_t cp_patterns[CP_LED_COUNT][CP_PINS_COUNT] = {
// IO27 IO26 IO25 IO24
{1, 2, 0, 2}, // D1
{0, 2, 1, 2}, // D2
{1, 0, 2, 2}, // D3
{2, 1, 0, 2}, // D4
{2, 2, 1, 0}, // D5
{0, 1, 2, 2}, // D6
{2, 0, 1, 2}, // D7
{2, 2, 0, 1}, // D8
};
数字1和0代表高电平和低电平,数字2代表高阻态,设置为
gpio_set_dir(pin, GPIO_IN);
gpio_disable_pulls(pin);
三、数码管
数码管是共阴极,位选是低电平有效,故先向位移寄存器发送位选数据,再发送段码数据,再发送RCK时钟信号,将数据锁存到寄存器并进行输出
static void hc595_write_raw(uint8_t digit_select, uint8_t segment) {
// 串联顺序: MCU -> 第一个595(段码) -> 第二个595(位选)
// 先移入的数据会被推到第二个595,所以先发位选,再发段码
hc595_shift_byte(digit_select);
hc595_shift_byte(segment);
hc595_latch(); //时钟信号
}
段码数据为
// a
// ---
// f | | b
// -g-
// e | | c
// ---
// d .dp
static const uint8_t SEGMENT_MAP[10] = {
// 共阴极: 1=亮, 0=灭
// .gfedcba
0b00111111, // 0: a,b,c,d,e,f
0b00000110, // 1: b,c
0b01011011, // 2: a,b,d,e,g
0b01001111, // 3: a,b,c,d,g
0b01100110, // 4: b,c,f,g
0b01101101, // 5: a,c,d,f,g
0b01111101, // 6: a,c,d,e,f,g
0b00000111, // 7: a,b,c
0b01111111, // 8: a,b,c,d,e,f,g
0b01101111, // 9: a,b,c,d,f,g
};
位选数据为
// QB (0x01) -> 个位, QA (0x02) -> 十位 低电平有效
#define NIXIE_LEFT_MASK 0x02u
#define NIXIE_RIGHT_MASK 0x01u
#define NIXIE_BLANK 0x03u
四、按键
ADC读取原始数据后,会先进行中值滤波,再进行移动平均滤波,先削掉尖峰再做平滑处理,这样可以得到稳定清晰的 ADC值,并且可以将偏差范围设置为±3。按照此方案得到的按键电压值如下
// 按键ADC参数参考值: K1到K4为按键 K5到K8为拨码开关
// K1 2837 mV (ADC=3522)
// K2 2651 mV (ADC=3291)
// K3 2288 mV (ADC=2841)
// K4 1547 mV (ADC=1921)
// K5 3017 mV (ADC=3745)
// K6 3001 mV (ADC=3726)
// K7 2976 mV (ADC=3695)
// K8 2931 mV (ADC=3639)
中值滤波处理方式为
static uint16_t median_filter(uint16_t sample) {
median_buf[median_idx] = sample;
median_idx = (median_idx + 1u) % MEDIAN_WINDOW;
uint16_t sorted[MEDIAN_WINDOW];
memcpy(sorted, median_buf, sizeof(sorted));
for (uint8_t i = 0; i < MEDIAN_WINDOW - 1u; i++) {
for (uint8_t j = 0; j < MEDIAN_WINDOW - 1u - i; j++) {
if (sorted[j] > sorted[j + 1u]) {
uint16_t temp = sorted[j];
sorted[j] = sorted[j + 1u];
sorted[j + 1u] = temp;
}
}
}
return sorted[MEDIAN_WINDOW / 2u];
}
移动平均滤波的处理方式为:
static uint16_t moving_average_filter(uint16_t sample) {
ma_accum -= ma_buf[ma_idx];
ma_buf[ma_idx] = sample;
ma_accum += sample;
ma_idx = (ma_idx + 1u) & (FILTER_LEN - 1u);
if (ma_idx == 0u) {
ma_filled = true;
}
if (ma_filled) {
return (uint16_t)(ma_accum / FILTER_LEN);
}
return (uint16_t)(ma_accum / (ma_idx == 0u ? FILTER_LEN : ma_idx));
}
滤波处理后的值会和按键电压值进行比较
static int8_t detect_key(uint16_t adc_value) {
for (uint8_t i = 0; i < KEY_COUNT; i++) {
int16_t diff = (int16_t)adc_value - (int16_t)key_adc_values[i];
if (diff < 0) diff = -diff;
if (diff <= KEY_TOLERANCE) {
return (int8_t)i;
}
}
return -1;
}
故按键识别过程如下:
uint16_t raw = adc_read();
uint16_t median_val = median_filter(raw);
uint16_t avg_val = moving_average_filter(median_val);
int8_t key = detect_key(avg_val);
消抖方案需要配合主程序的时间片和非阻塞计时实现,在此不做介绍。
五、主程序
主程序采用软件时间片调度,按照固定周期执行模块任务。
系统当前分为 4 个时间片:
- 1ms:按键采样、按键处理、单色 LED 刷新、看门狗检测
- 5ms:数码管扫描
- 10ms:灯效更新
- 500ms:串口输出状态信息
┌─────────────────────────────────────────────────────────────────┐
│ 系统初始化 │
│ (串口 / WS2812 / LED / 数码管 / ADC采集) │
└────────────────────────────────┬────────────────────────────────┘
│
┌────────────────────────────────▼────────────────────────────────┐
│ while(1) 主循环 │
└───────┬───────────────┬────────────────┬───────────────┬────────┘
│ │ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ 1ms 任务 │ │ 5ms 任务 │ │ 10ms 任务 │ │500ms 任务 │
├───────────┤ ├───────────┤ ├───────────┤ ├───────────┤
│LED控制 │ │数码管扫描 │ │ 灯效切换 │ │串口输出 │
│ 多路复用 │ │段码更新 │ │ │ │ │
├───────────┤ ├───────────┤ ├───────────┤ ├───────────┤
│ADC按键识别│ │ │ │WS2812控制 │ │ │
│看门狗 │ │ │ │ │ │ │
└───────────┘ └───────────┘ └───────────┘ └───────────┘
while (true) {
uint32_t now = to_ms_since_boot(get_absolute_time());
/* 1ms 任务:LED 多路复用刷新 + 按键 ADC 采样 */
if (task_due(now, &last_1ms, TASK_PERIOD_1MS)) {
watchdog_trigger_task(now);
led_update_task();
key_task();
process_key_actions(now);
}
/* 5ms 任务:数码管动态扫描(每位显示 5ms,刷新率 100Hz,无闪烁感) */
if (task_due(now, &last_5ms, TASK_PERIOD_5MS)) {
nixie_task((uint8_t)(g_effect_id + 1u), g_speed_level);
}
/* 10ms 任务:灯效步进 */
if (task_due(now, &last_10ms, TASK_PERIOD_10MS)) {
effect_update();
}
/* 500ms 任务:USB 状态输出 */
if (task_due(now, &last_500ms, TASK_PERIOD_500MS)) {
usb_print_task();
}
}
程序记录每个任务上一次执行的时间,当前时间与上一次执行时间的差值达到设定周期后,就执行对应任务。执行完成后立刻返回,不会停在某一个任务内部
待。另外,由于开发板没有引出RST引脚,所以使用看门狗用于手动复位,短接 IO28和附近的GND即可复位。
在模块任务内部,使用全局变量实现功能的切换,以 led_update_task为例
static void led_update_task(void) {
const bool multiplex_all_leds = (g_effect_id == 0 || g_effect_id == 1);
if (multiplex_all_leds) {
/* 呼吸/频闪:多路复用全部8颗(1ms轮换,125Hz,视觉均亮同步闪烁)
* g_led_current>=0 = 点亮阶段,-1 = 熄灭阶段 */
g_led_mux_idx = (g_led_mux_idx + 1u) % 8u;
if (g_led_current >= 0) {
cp_show_led(g_led_mux_idx);
} else {
cp_all_off();
}
} else {
/* 流水/随机:直接点亮指定编号的 LED */
if (g_led_current >= 0) {
cp_show_led((uint8_t)g_led_current);
} else {
cp_all_off();
}
}
}
每1ms执行一次的 led_update_task函数只负责通过 g_led_current的值,刷新显示对应的 led,而 g_led_current的切换则由每10ms执行一次的effect_update函数
负责
static void effect_update(void) {
uint32_t now = to_ms_since_boot(get_absolute_time());
if ((now - g_effect_last_ms) < speed_interval_ms(g_speed_level)) return;
g_effect_last_ms = now;
g_effect_tick++;
effect_table[g_effect_id].update(g_effect_tick);
}
static const effect_desc_t effect_table[EFFECT_COUNT] = {
{"Breath", effect_breath},
{"Strobe", effect_strobe},
{"Running", effect_chase},
{"Random", effect_random_flash},
};
/* ============================================================================
* 光效 3:随机闪烁
* 单色LED : 每步随机选一颗点亮
* WS2812 : 每步随机颜色
* ========================================================================== */
static void effect_random_flash(uint32_t tick) {
(void)tick;
uint8_t v = g_brightness;
g_led_current = (int8_t)(rand() % 8);
ws2812_set_pixel_raw(0, ws2812_hsv((uint8_t)(rand() & 0xFFu), 200, v));
ws2812_set_pixel_raw(1, ws2812_hsv((uint8_t)(rand() & 0xFFu), 200, v));
ws2812_show();
}
效果演示

模式1:呼吸灯

模式2:闪烁

模式3:流水灯

模式4:随机
串口输出
遇到的难题与解决办法
ws2812无法点亮
树莓派官方例程默认没有开启 IO46的 pio功能,需要额外配置,通过外接 ws2812和硬禾科技提供的测试固件,排查确认了软硬件均正常,最终可以把问题锁定在 IO配置上面。
开发环境配置失败
开发环境大多会遇到工具链配置冲突、网络无法下载两个难题,一般可以通过虚拟机和换源解决,或者查找其它平台的教程,可以下载到离线的SDK。
活动感想
本项目完整实现了从底层驱动到应用层的开发,让我深入理解了架构设计在开发中的巨大作用。领悟到了紧凑型 IO
在实际开发中的优缺点和技术难点。同时感谢硬禾科技提供的开发平台,在此非常钦佩此平台精妙的任务设计。