​ 这篇文章是我在实习期间,关于兆鸣老师 C 面向对象写法的一段实战。

​ 起因是刚来实习,师傅叫我去写流水灯,可能是不清楚我的能力边界,外加自己比较忙,所以没下什么难的任务,于是随便拿了块之前项目上的板子,和不久前在那个项目上验证功能写的 demo 程序,让我把流水灯的灯效写一写(流水灯是通过查表实现的,24位的16进制码一帧一帧往下放),看看能写出多少种花来......

​ 我心里那个急啊......大老远跑来,可别让我就写流水灯了吧。

​ 于是 AI 帮我解决了一切。

​ 继续闲着也不好,我想起来不久前学的 C 面向对象写驱动的方式,好处在于后续加多少条灯带都行,好维护,不容易被更改

废话不多说,以下。


一、重新封装

​ 项目环境是 Keil-MDK,STM32F0,标准库,单片机16Kb flash,4Kb ram,这对一个当前项目已经占用90% flash,且玩了很久 ESP32 的我来说简直是天灾(毕竟由奢入俭难),那么接下来我们就来拜读一下老师傅的代码。

#include "lifter.h"
#include "rgb_led.h"
#include "delay.h"
#include "core_cm0.h"

// 灯效表
const uint32_t rgb_style1[][2] = {

    0x000000,0x000000,
    0x500000,0x000000,
    0x005000,0x000000,
    0x000050,0x000000,
    0x000000,0x500000,
    0x000000,0x005000,
    0x000000,0x000050,
    
    0x000000,0x005000,
    0x000000,0x500000,
    0x000050,0x000000,
    0x005000,0x000000,
    0x500000,0x000000,
    0x000000,0x000000,
    
    0x505000,0x000000,
    0x000050,0x500000,
    0x000050,0x005050,
    0x000050,0x500000,
    0x505000,0x000000,
    0x000000,0x000000,
    
    0x505050,0x505050,
    0x404040,0x404040,
    0x303030,0x303030,
    0x202020,0x202020,
    0x101010,0x101010,
    0x000000,0x000000,

    0x101010,0x101010,
    0x202020,0x202020,
    0x303030,0x303030,
    0x404040,0x404040,
    0x505050,0x505050,
    
    0x404040,0x404040,
    0x303030,0x303030,
    0x202020,0x202020,
    0x101010,0x101010,
    0x000000,0x000000,
    0x101010,0x101010,
    0x202020,0x202020,
    0x303030,0x303030,
    0x404040,0x404040,
    0x505050,0x505050,
    0x606060,0x606060,
    0x707070,0x707070,
    0x808080,0x808080,
    0x707070,0x707070,
    0x606060,0x606060,
    0x505050,0x505050,
    0x404040,0x404040,
    0x303030,0x303030,
    0x202020,0x202020,
    0x101010,0x101010,
    0x000000,0x000000,
    0x101010,0x101010,
    0x202020,0x202020,
    0x303030,0x303030,
    0x404040,0x404040,
    0x505050,0x505050,
    0x606060,0x606060,
    0x707070,0x707070,
    0x808080,0x808080,
    
    0x007070,0x707000,
    0x000060,0x600000,
    0x000050,0x500000,
    0x000040,0x400000,
    0x000030,0x300000,
    0x000020,0x200000,
    0x000010,0x100000,
    0x000000,0x000000,
    0x000010,0x100000,
    0x000020,0x200000,
    0x000030,0x300000,
    0x000040,0x400000,
    0x000050,0x500000,
    0x005000,0x005000,
    0x500000,0x000050,
    0x005000,0x005000,
    0x000050,0x500000,
    0x000000,0x000000,
    
};

// 两个2811数据发送间隔
void rgb_send_reflash(void)
{
    RGB_LED_CLR();
    delay_us(280);
}

// 连续发送24bit,__nop()控制时序
void rgb_send_24bit(uint32_t color)
{
    uint32_t i;
    uint32_t rgb24 = color;
    for(i=0;i<24;i++)
    {
        RGB_LED_SET();
        if(rgb24 & 0x00800000)
        {
            __nop();__nop();__nop();__nop();
            __nop();__nop();__nop();__nop();
            __nop();__nop();__nop();__nop();
            __nop();__nop();__nop();__nop();
            
        }
        else
        {
            __nop();__nop();__nop();__nop();
            
        }
        
        RGB_LED_CLR();
        __nop();__nop();__nop();__nop();
        __nop();__nop();__nop();__nop();
        __nop();__nop();__nop();__nop();
        __nop();__nop();__nop();__nop();
        rgb24 = rgb24 << 1;    // 这里很巧妙,掩码不动,数据移位控制时序,如果是我的话要写for循环查表了
        
    }
}

uint32_t my_led_color_buff[RGB_LED_IC_NUM];    // 用了2811芯片的数量宏来确定发送缓冲区数量
void rgb_led_reflash_data(uint32_t *led_color)    // 发数据的时候细节关中断,老师傅就是老师傅,如果是我估计等到出bug都想不起来
{
    uint32_t i;
    __disable_irq();
    for(i=0;i<RGB_LED_IC_NUM;i++)
    {
        rgb_send_24bit(led_color[i]);
    }
    __enable_irq();
    rgb_send_reflash();
    
}


uint32_t rgb_led_deal_count=0;    // 帧数计数器,一行就是一帧
uint32_t mycolor=0;
uint32_t mycolor_flg=0;
//100ms
void rgb_led_deal(void)
{
    rgb_led_reflash_data((uint32_t *)&rgb_style1[rgb_led_deal_count][0]);    // 每帧第0个元素启发
    rgb_led_deal_count++;
    if(rgb_led_deal_count >= 77)
        rgb_led_deal_count = 0;
}


void rgb_init(void)
{
    my_led_color_buff[0]=0;
    my_led_color_buff[1]=0;
    rgb_send_reflash();
    rgb_led_reflash_data(my_led_color_buff);
    rgb_send_reflash();
}

​ 大概就是这样,拿两个缓冲区发个数据,至于 lifter.h ,那是老师傅直接拿的旧工程的推杆引脚飞线,做了点引脚初始化和嵌套宏定义,不用管。

​ 还有.h文件:

#ifndef __RGB_LED_H
#define __RGB_LED_H 

#include "prj_includes.h"

#define RGB_LED_IC_NUM      2

//100MS
#define RGB_LED_TIMEOUT      50

void rgb_init(void);
void rgb_led_deal(void);

#endif

​ 相当简单,主函数调用一次 init ,然后靠定时器2ms配合RGB_LED_TIMEOUT 宏实现每隔100ms rgb_led_deal一次,灯就转起来了

​ 但是我们从封装角度能看到不少问题,第一是和旧组件 lifter.h 耦合,因为毕竟只是个demo。

​ 第二是全局变量太多了。

my_led_color_buff 是发送缓冲区,rgb_led_deal_count 是帧数计数器,mycolormycolor_flg 也是这个模块里的状态。它们都放在了 .c 文件的全局作用域里。现在项目里只有一条灯带,看起来好像没什么问题,但如果后面再接一条灯带,就会变成这样:

rgb_init();          // 初始化第一条灯带
rgb_init();          // 初始化第二条灯带
rgb_led_deal();      // 到底处理的是哪一条?

​ 第二次 rgb_init() 会覆盖第一次初始化留下来的状态。第二条灯带如果要有自己的帧计数器、自己的颜色缓冲区,只能继续复制一套全局变量,然后再把函数名改成 rgb2_led_deal()。这条路走下去,代码会越来越像 Excel 里复制出来的两列,能跑,但是谁也不敢动。

​ 所以这次先把脚步放慢一点,先把数据的主人找出来。

二、把数据归还给对象

​ 按照兆鸣老师教材里“数据三级分类”的说法,先问每一份数据到底属于谁。灯带自己的颜色缓冲区和帧计数器,属于某一个灯带实例;模块内部的灯效表是共享的只读数据;GPIO 的底层发送函数则应该藏在 RGB 模块内部。

​ 于是我把原来的全局变量改成了一个结构体:

struct rgb_led {
    uint32_t color_buff[RGB_LED_IC_NUM];
    uint32_t deal_count;
    uint16_t idle_ticks;
    uint8_t ambient_index;
    enum rgb_led_state state;
    volatile uint8_t update_pending;
    volatile uint8_t ws2811_busy;
};

​ 这里没有写 typedef struct rgb_led rgb_led_t。完整结构体放在头文件里,调用者能直接看到对象有多少字段、要占多少资源。至于 typedef,先不用,主要是想让代码里每次出现对象类型时,都能看见它确实是一个 struct。虽然现在 flash 空间还够用(无关项目代码删掉以后),这个习惯还是值得养起来,具体可以去看兆鸣老师的那本书,真的不错。

​ 初始化时由调用者提供对象的地址:

struct rgb_led rgb_8;

int main(void)
{
    Config();
    rgb_led_init(&rgb_8);

    while (1)
    {
        rgb_led_deal(&rgb_8);
    }
}

​ 如果以后需要第二条灯带,只需要再创建一个对象。状态不会互相覆盖,发送缓冲区也不会互相覆盖。

struct rgb_led left_strip;
struct rgb_led right_strip;

rgb_led_init(&left_strip);
rgb_led_init(&right_strip);

​ 这就是 C 里最朴素的对象:一块保存状态的内存,加上一组接收它地址的函数。没有 class 关键字,但已经有了对象和方法。

三、.h 是合同,.c 是实现

​ 原来的头文件只暴露了两个无参数函数:

void rgb_init(void);
void rgb_led_deal(void);

​ 重构之后,头文件变成 RGB 模块对外的合同:

#ifndef __RGB_LED_H
#define __RGB_LED_H

#include "prj_includes.h"

#define RGB_LED_IC_NUM  8
#define RGB_LED_TIMEOUT 50

struct rgb_led {
    uint32_t color_buff[RGB_LED_IC_NUM];
    uint32_t deal_count;
    uint16_t idle_ticks;
    uint8_t ambient_index;
    enum rgb_led_state state;
    volatile uint8_t update_pending;
    volatile uint8_t ws2811_busy;
};

int rgb_led_init(struct rgb_led *me);
int rgb_led_deal(struct rgb_led *me);
void rgb_led_tick_2ms(struct rgb_led *me);
int rgb_led_set_run(struct rgb_led *me);
int rgb_led_ambient_next(struct rgb_led *me);

#endif

​ 应用层只需要知道 struct rgb_led 和这些公开函数,不需要知道 24 位数据是怎么发出去的,也不需要知道某个灯效表在 .c 文件的第几行。

​ 真正的发送函数放到 .c 里,并且加上 static

static void rgb_send_reflash(void)
{
    RGB_LED_CLR();
    delay_us(280);
}

static void rgb_send_24bit(uint32_t color)
{
    uint32_t i;
    uint32_t rgb24 = color;

    for (i = 0; i < 24; i++)
    {
        RGB_LED_SET();

        if (rgb24 & 0x00800000)
        {
            __nop(); __nop(); __nop(); __nop();
            __nop(); __nop(); __nop(); __nop();
            __nop(); __nop(); __nop(); __nop();
            __nop(); __nop(); __nop(); __nop();
        }
        else
        {
            __nop(); __nop(); __nop(); __nop();
        }

        RGB_LED_CLR();
        __nop(); __nop(); __nop(); __nop();
        __nop(); __nop(); __nop(); __nop();
        __nop(); __nop(); __nop(); __nop();
        __nop(); __nop(); __nop(); __nop();

        rgb24 <<= 1;
    }
}

​ 这里用 static 没有什么装逼成分,它只是明确告诉链接器:这些函数只服务于 rgb_led.c,其他模块没有理由直接调用它们。外部如果想刷新灯带,走 rgb_led_deal() 这个公开入口。

四、一条级联灯带的一帧到底是什么

​ 参考代码只有两个颜色数据,所以很容易把一个数组元素误认为一帧。实际上,WS2811 级联灯带的一帧是:按级联顺序发送所有芯片的 24 位颜色数据,然后保持一段低电平作为 reset/latch。

​ 现在项目里有 8 颗 WS2811 芯片,因此一帧是:

8 × 24 bit + reset/latch

​ 发送代码也应该围绕这个事实写:

static void rgb_led_reflash_data(uint32_t *led_color)
{
    uint32_t i;

    __disable_irq();

    for (i = 0; i < RGB_LED_IC_NUM; i++)
        rgb_send_24bit(led_color[i]);

    __enable_irq();
    rgb_send_reflash();
}

​ 这里的 color_buff 装的是整条级联链路这一帧要发送的 8 个颜色值,描述的是整帧数据。芯片、灯组、通道和帧缓冲区是四个不同的概念,现场看见几颗灯,代码里的数量关系也不能跟着混在一起。

五、把氛围灯从“数组下标”变成“对象状态”

​ 原来的 rgb_led_deal() 每 100ms 取一行表,然后把计数器加一:

rgb_led_reflash_data((uint32_t *)&rgb_style1[rgb_led_deal_count][0]);
rgb_led_deal_count++;

​ 这适合验证“灯能不能亮”,但应用层只能机械地让帧数加一,不知道当前播放的是哪一段氛围灯。重构后,先由 API 修改对象状态,再由 rgb_led_deal() 根据状态生成当前帧:

static const uint16_t rgb_style_offset[] =
    {0, 15, 31, 47, 63, 79, 87, 95, 105, 113};

static const uint8_t rgb_style_length[] =
    {15, 16, 16, 16, 16, 8, 8, 10, 8, 12};

static int rgb_led_render_ambient(struct rgb_led *me)
{
    uint32_t i;
    uint16_t frame;

    if (me == 0)
        return -22;

    frame = (uint16_t)(rgb_style_offset[me->ambient_index] +
             (me->deal_count % rgb_style_length[me->ambient_index]));

    for (i = 0; i < RGB_LED_IC_NUM; i++)
        me->color_buff[i] = rgb_style[frame][i];

    return 0;
}

/* rgb_led_deal() 中的状态分流 */
int ret;

if (me->state == RGB_LED_STATE_AMBIENT)
{
    ret = rgb_led_render_ambient(me);
}
/* else:项目原有的其他状态渲染逻辑 */

​ 这里摘的是 rgb_led_deal() 中和氛围灯有关的分流。前面的空指针检查、忙状态检查和后面的发送收尾,项目代码都保留着。灯效表只负责保存画面,当前播放哪一段、这一段播放到第几帧,则由对象里的 ambient_indexdeal_count 负责。

​ 这里的两个数组很重要。rgb_style_offset 记录每一段氛围灯在总表中的起始位置,rgb_style_length 记录这一段有多少帧。这样就不用把十段效果拆成十个数组,也不用让 rgb_led_deal() 里出现一大串魔法下标。

int rgb_led_ambient_next(struct rgb_led *me)
{
    if (me == 0)
        return -22;

    me->ambient_index++;
    if (me->ambient_index >= 10)
        me->ambient_index = 0;

    me->state = RGB_LED_STATE_AMBIENT;
    me->deal_count = 0;
    me->idle_ticks = 0;
    me->update_pending = 1;

    return 0;
}

​ 这比在应用层直接写 rgb_style[113 + count] 好得多。应用层只说“切换到下一段氛围灯”,至于这一段从哪一行开始、共有多少行,是 RGB 模块自己的事情。

​ 项目里的 state 还要负责区分当前灯效。进入 RGB_LED_STATE_AMBIENT 后,rgb_led_deal() 才会调用上面的 rgb_led_render_ambient(),按照偏移和长度算出当前帧。这里先把注意力放在氛围灯这一条路径上,其他状态的渲染代码就不展开了。

​ 氛围灯的自动进入交给 2ms 节拍函数。比如累计 30000 次,也就是大约 60 秒没有新的操作,项目代码会把对象切换到氛围灯状态。下面只截取和这件事有关的部分:

void rgb_led_tick_2ms(struct rgb_led *me)
{
    if (me == 0)
        return;

    if (me->idle_ticks < 30000)
        me->idle_ticks++;

    if (me->idle_ticks >= 30000 &&
        me->state == RGB_LED_STATE_RUN)
    {
        me->state = RGB_LED_STATE_AMBIENT;
        me->deal_count = 0;
        me->update_pending = 1;
    }
}

​ 注意这里的定时器只负责推进时间和改变状态,不负责发送 WS2811 数据。真正的发送仍然在主循环的 rgb_led_deal() 中完成。项目主循环里还保留了 RGB_LED_TIMEOUT,所以氛围灯会按照 100ms 的节奏继续换帧:

if (rgb_led_timeout_count == 0 ||
    rgb_led_has_update(&rgb_8))
{
    rgb_led_deal(&rgb_8);
    rgb_led_timeout_count = RGB_LED_TIMEOUT;
}

六、这里暂时不上 ops 表

​ 教材后面讲了函数指针、ops 操作表和多态。那我这次为什么没有给 WS2811 写一个 struct rgb_led_ops

​ 因为当前项目只有一种具体实现:STM32F0 上 PA4 直接 bit-bang WS2811。没有 GPIO 版 WS2811、SPI 版 WS2811、RMT 版 WS2811 需要互相替换。为了“看起来像 C++”,硬塞一个 ops 表,只会多一层跳转,增加阅读成本,并不会带来真正的扩展能力。

​ 这也是这本书里我觉得很重要的一句话:抽象要跟着变化走。当前真正需要的是封装和实例化,所以先做到:

  • 数据进 struct rgb_led
  • 公共操作使用 rgb_led_xxx(me)
  • 底层函数和灯效表使用 static
  • 硬件细节留在 RGB 模块内部

​ 等以后真的出现两种发送实现,再把共同接口提取成 base 和 ops 表也不迟。

七、时序不能因为重构就随便动

​ 这次重构最容易犯的错误,是觉得代码已经对象化了,于是顺手把 GPIO 操作、NOP 数量和中断控制也一起优化。

​ 但显然 WS2811 是靠波形宽度识别 0 和 1 的。以下内容属于硬件时序边界:

  • 发送顺序是高位在前
  • 每个 bit 都要按原来的高低电平节奏发送
  • 整帧发送期间关闭中断
  • 所有芯片数据发送完后保持约 280 μs 低电平
  • GPIO 初始化速度、编译器优化等级和时钟频率都可能影响最终波形

​ 这次实际上干了个很自作聪明的事,在 .h 文件中定义这两个宏的时候:

#define RGB_LED_SET()       GPIO_SetBits(RGB_LED_PORT, RGB_LED_PIN)
#define RGB_LED_CLR()       GPIO_ResetBits(RGB_LED_PORT, RGB_LED_PIN)

​ 第一遍实际上用了操作 BSRRBRR 两个寄存器,想着直接操作寄存器会快不少,但是事实证明,老师傅的时序是严格计算的,我用了寄存器来翻转时,灯带闪的人眼花缭乱,根本不按我给的颜色来

八、这次重构实际换来了什么

​ 改完之后,主循环面对的是一个 RGB 对象,散落在各处的全局变量收回去了。比如启动后切到下一段氛围灯,主循环仍然按照项目原来的超时计数器节奏刷新:

struct rgb_led rgb_8;
u16 rgb_led_timeout_count = 0;

rgb_led_init(&rgb_8);
rgb_led_ambient_next(&rgb_8);

while (1)
{
    if (rgb_led_timeout_count == 0 ||
        rgb_led_has_update(&rgb_8))
    {
        rgb_led_deal(&rgb_8);
        rgb_led_timeout_count = RGB_LED_TIMEOUT;
    }
}

​ 以后增加第二条灯带,状态和缓冲区可以各自放进第二个对象。真正接到不同的物理引脚,还要继续把 GPIO 端口和引脚从宏里收进对象,当前项目的 PA4 映射暂时没有做到这一步。增加一段氛围灯,主要是增加灯效表以及对应的偏移和长度;应用层不需要知道颜色缓冲区有几个元素,也不应该直接修改 ambient_indexdeal_count

​ 当然,这离工业级驱动还有距离。当前结构体字段仍然是公开的,只能依靠 API 规范和代码评审来实现“软私有”;错误码也还可以继续统一;如果未来需要替换发送后端,再考虑引入 ops 表。这次重构先把变化边界画出来,后面再一点点往前走。

九、如果你也想学这套写法(不是广告,真感觉很好)

​ 这次实战背后的教材是兆鸣老师的《C 语言面向对象编程·嵌入式实战》,在线免费阅读:

​ 我这次 RGB 重构最直接对应的是第一部分的封装:先把数据归位,再用 static 隐藏模块内部实现,最后把硬件映射限制在驱动模块里。不用一上来就背 container_ofops,先把一个全局变量很多、边界混乱的 demo 拆干净,面向对象就已经开始了。

​ 封装是藏细节,继承是共享不变,多态是各自精彩。对于这条 WS2811 灯带来说,这次先完成了第一步:把氛围灯的播放状态和发送细节藏回它们应该待的地方。


以上,感谢师傅暂时没什么事让我干,忙里偷闲写下了个人博客的第一篇经验分享
以后会更新更多的(如果不懒的话)