2026寒假练 - 基于RP2350B实现光效与调节
本项目实现了硬禾科技2026寒假练 RP2350B核心板的任务 2,使用 RP2350B核心板及部分硬件,实现了在紧凑型 IO口的按键识别、数码管扫描显示和灯效控制。
标签
嵌入式系统
ADC
koshi
更新2026-03-24
湖州学院
12

任务介绍

  • 用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,

blankadc_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

在实际开发中的优缺点和技术难点。同时感谢硬禾科技提供的开发平台,在此非常钦佩此平台精妙的任务设计。


附件下载
RP2350_0308.rar
项目源代码
RP2350_0308.uf2
项目固件
团队介绍
个人
团队成员
koshi
评论
0 / 100
查看更多
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号