这篇文章是我在实习期间,关于兆鸣老师 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 是帧数计数器,mycolor 和 mycolor_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_index 和 deal_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) 第一遍实际上用了操作 BSRR 和 BRR 两个寄存器,想着直接操作寄存器会快不少,但是事实证明,老师傅的时序是严格计算的,我用了寄存器来翻转时,灯带闪的人眼花缭乱,根本不按我给的颜色来
八、这次重构实际换来了什么
改完之后,主循环面对的是一个 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_index 或 deal_count。
当然,这离工业级驱动还有距离。当前结构体字段仍然是公开的,只能依靠 API 规范和代码评审来实现“软私有”;错误码也还可以继续统一;如果未来需要替换发送后端,再考虑引入 ops 表。这次重构先把变化边界画出来,后面再一点点往前走。
九、如果你也想学这套写法(不是广告,真感觉很好)
这次实战背后的教材是兆鸣老师的《C 语言面向对象编程·嵌入式实战》,在线免费阅读:
我这次 RGB 重构最直接对应的是第一部分的封装:先把数据归位,再用 static 隐藏模块内部实现,最后把硬件映射限制在驱动模块里。不用一上来就背 container_of 和 ops,先把一个全局变量很多、边界混乱的 demo 拆干净,面向对象就已经开始了。
封装是藏细节,继承是共享不变,多态是各自精彩。对于这条 WS2811 灯带来说,这次先完成了第一步:把氛围灯的播放状态和发送细节藏回它们应该待的地方。
以上,感谢师傅暂时没什么事让我干,忙里偷闲写下了个人博客的第一篇经验分享
以后会更新更多的(如果不懒的话)