上次分享了关于封装的心得和体会,这次上点稍微复杂的。
兆鸣老师的书里依旧有,但是我也刚学没多久,不一定理解得透。我把教材做成了 Skill,再把自己的代码给 AI 对照,编译、烧录、上灯带验证,确定目前这条路能跑通以后,才敢来写点自己的理解。
上一篇我还专门写了一节“这里暂时不上 ops 表”。当时只有 STM32F0 上 PA4 这一种发送方式,硬塞一张操作表进去,除了让代码看起来高级一点,确实没什么实际作用。
这一次也不是打开工程就开始写函数指针。我先重新划分每个函数应该归谁,再把 STM32F0 相关代码迁到独立文件。等协议层和平台层之间出现一组稳定的跨层操作以后,才继续把它们提取成 ops。
最后形成的结构很简单:
ws2811.c 通用对象和协议处理
ws2811_stm32f0.c STM32F0 的 GPIO 和发送时序不过真正值得记录的,是这两个文件为什么要这样分,以及操作表是怎么从它们之间长出来的。
一、先处理软件时序留下的问题
开始分层前,遇到了一个底层问题:重构后的灯带全部亮了。
当时手上没有逻辑分析仪,没法直接测 T0H、T1H 和 RESET。我能找到的最可靠参照,是旧工程里已经在同一块硬件上跑通的发送代码。
先对比 C 源码中的 _nop() 数量和分布,调整以后问题还在。但是时序问题毕竟不能靠猜,所以接着比较 ARMCC5 生成的反汇编,把一次 bit 发送经过的完整路径拉出来,逐条统计高电平和低电平阶段的指令,再结合各条指令的执行周期核对总周期数。
最后发现,影响时序的不只有 _nop()。
对象化以后,发送循环里每次都要多走几层指针:
me->config->platform_config源码看着只是多解引用了几次,落到 Cortex-M0 上却会变成实际的取地址和访存指令。WS2811 的高低电平本来就是靠执行时间凑出来的,这些额外指令自然也被算进了脉宽。
最后把时序循环需要的端口和引脚提前取出来缓存:
GPIO_TypeDef *port = config->port;
uint16_t pin = config->pin;关键循环里只使用局部变量,再按照可行版本核对完整指令路径。修改后重新烧录,灯带恢复正常。
这次排查给后面的重构立下了一条规矩:发送函数可以移动位置、修改归属,但不要顺手改写它的关键路径。
在没有逻辑分析仪的时候,可以先找同平台、同编译器、同主频下已经跑通的代码作为基线,然后对比反汇编。只看 C 代码和 NOP 数量不够,真正影响时序的是编译器生成的指令。
当然,这种办法只能帮助恢复已知可行的实现。灯带正常工作不等于时序参数已经经过仪器测量,有条件以后还是应该把逻辑 0、逻辑 1 和 RESET 波形抓出来。
二、先问函数属于谁,再决定往哪里搬
时序稳定以后,我们开始看 ws2811.c 里混在一起的函数。
如果一上来就新建文件,然后凭感觉剪切粘贴,很容易变成“这个函数好像放哪边都行”。所以先不搬,逐个问它们依赖什么、处理的规则会不会随平台变化。
第一类是通用对象和协议规则:
ws2811_validate_config(); // 结构体检验
ws2811_validate_object(); // 对象检验
ws2811_init();
ws2811_write();
ws2811_deinit();ws2811_write() 要检查对象有没有初始化、待发送数组是不是空、数据个数是否等于级联 IC 数量、每个颜色是否在 24 bit 范围内。这些规则换到另一颗 MCU 以后依然成立,所以继续留在 ws2811.c。
if (frame == 0)
return WS2811_EINVAL;
if (frame_ic_count != me->config->ic_count)
return WS2811_EMSGSIZE;
for (i = 0; i < frame_ic_count; i++) {
if (frame[i] > 0x00FFFFFF)
return WS2811_EINVAL;
}第二类函数明显依赖 STM32F0:
GPIO 初始化
GPIO 端口和引脚检查
发送 RESET 低电平
按时序发送 24 bit
关中断并发送整帧它们会用到这些东西:
GPIO_TypeDef
RCC_AHBPeriphClockCmd()
GPIO_Init()
GPIO_SetBits()
GPIO_ResetBits()
__get_PRIMASK()
__disable_irq()换成另一款 MCU,GPIO 类型、时钟初始化、中断接口和发送方式都可能变化。这批函数应该从通用文件里出去。
到这里,划分依据就有了:
描述 WS2811 对象和数据规则的,留在 ws2811.c
描述 STM32F0 如何产生波形的,移到 STM32F0 平台层先把归属想清楚,再开始迁移。这样每移动一个函数,都知道自己想隔离的变化是什么。
三、新建 ws2811_stm32f0.c/.h
接下来是这次重构的重头戏:新建 STM32F0 平台文件。
WS2811/
├── ws2811.c
├── ws2811.h
├── ws2811_stm32f0.c
└── ws2811_stm32f0.hws2811_stm32f0.h 先声明平台需要的配置:
struct ws2811_stm32f0_config {
GPIO_TypeDef *port;
uint16_t pin;
};然后按照刚才确定的归属迁移函数。
1. 迁移平台配置检查
端口是不是 STM32F0 支持的 GPIOA、GPIOB 或 GPIOF,只有这个平台需要知道:
static int ws2811_stm32f0_validate_config(
const struct ws2811_stm32f0_config *config)
{
if (config == 0 || config->port == 0 || config->pin == 0)
return WS2811_EINVAL;
if (config->port != GPIOA &&
config->port != GPIOB &&
config->port != GPIOF)
return WS2811_EINVAL;
return 0;
}2. 迁移 GPIO 初始化
GPIO 时钟和标准库初始化全部进入平台文件:
struct ws2811_stm32f0_config {
GPIO_TypeDef *port;
uint16_t pin;
};
static int ws2811_stm32f0_gpio_init(const struct ws2811_stm32f0_config *config)
{
GPIO_InitTypeDef gpio;
uint32_t gpio_clock;
if (config->port == GPIOA)
gpio_clock = RCC_AHBPeriph_GPIOA;
else if (config->port == GPIOB)
gpio_clock = RCC_AHBPeriph_GPIOB;
else if (config->port == GPIOF)
gpio_clock = RCC_AHBPeriph_GPIOF;
else
return WS2811_EINVAL;
RCC_AHBPeriphClockCmd(gpio_clock, ENABLE);
gpio.GPIO_Pin = config->pin;
gpio.GPIO_Speed = GPIO_Speed_Level_3;
gpio.GPIO_Mode = GPIO_Mode_OUT;
gpio.GPIO_OType = GPIO_OType_PP;
gpio.GPIO_PuPd = GPIO_PuPd_NOPULL;
GPIO_Init(config->port, &gpio);
GPIO_ResetBits(config->port, config->pin);
return 0;
}ws2811.c 从此不需要知道 F0 的 GPIO 时钟如何打开。
3. 迁移 RESET 和 24 bit 发送
RESET 低电平也由平台负责产生:
static void ws2811_stm32f0_send_reset(const struct ws2811_stm32f0_config *config)
{
GPIO_ResetBits(config->port, config->pin);
delay_us(280);
}已经修复并验证过的 24 bit 发送函数整体搬入平台文件,保留局部 port、pin 和原来的 NOP 分布:
static void ws2811_stm32f0_send_24bit(
const struct ws2811_stm32f0_config *config,
uint32_t data)
{
GPIO_TypeDef *port = config->port; // 缓存
uint16_t pin = config->pin; // 缓存
uint32_t i;
for (i = 0; i < 24; i++)
{
GPIO_SetBits(port, pin);
if (data & 0x00800000)
{
——nop()...
}
else
{
——nop()...
}
GPIO_ResetBits(port, pin);
——nop()...
data <<= 1;
}
}4. 迁移整帧发送
关中断、遍历所有级联 IC、恢复中断和发送 RESET 是一个完整的平台动作,因此一起迁移:
static int ws2811_stm32f0_write_frame(
const void *platform_config,
const uint32_t *frame,
uint32_t frame_ic_count)
{
const struct ws2811_stm32f0_config *config = platform_config;
uint32_t primask;
uint32_t i;
primask = __get_PRIMASK();
__disable_irq();
for (i = 0; i < frame_ic_count; i++)
ws2811_stm32f0_send_24bit(config, frame[i]);
if (primask == 0)
__enable_irq();
ws2811_stm32f0_send_reset(config);
return 0;
}迁移到这里以后,ws2811_stm32f0.c 已经能完整回答一个问题:
给我一帧 24 bit 数据,在 STM32F0 上该怎么发出去?
以后增加别的平台,不需要继续往这个文件里塞条件编译,可以新建对应的平台层:
ws2811_stm32g0.c/.h
ws2811_spi.c/.h
ws2811_esp32_rmt.c/.h它们各自实现 GPIO、定时器、SPI、DMA 或 RMT 的发送方式。ws2811.c 继续处理同一套对象和帧规则。
四、跨文件以后,先把“谁调用谁”画清楚
函数迁移以后立刻遇到的是可见性问题。
static 函数只能在当前 .c 文件内部使用。如果 ws2811.c 要直接调用某个平台函数,这个入口就必须对外声明;平台文件内部使用的 GPIO 初始化、RESET 和 24 bit 发送仍然可以是 static。
关键不在于“平台层函数可见性强不强”,要看实际调用方向:
ws2811.c
↓ 调用平台入口
ws2811_stm32f0.c
↓ 调用内部辅助函数
GPIO / 中断 / delay因此第一次接通两个文件时,跨层入口放进 ws2811_stm32f0.h,内部辅助函数留在 .c:
跨文件调用的入口 需要声明并对外可见
平台文件内部的辅助函数 继续 static这里不能见到“平台层”三个字,就把里面所有函数都去掉 static。也不能为了封装全部加上 static,然后让另一个文件根本找不到入口。
我们在这一步重新整理了函数声明、头文件依赖和参数类型。代码能编译以后立即重新烧录,确认函数移动到新文件后没有破坏发送时序。
到此为止,我们得到的是一个固定连接:
ws2811.c
↓
ws2811_stm32f0.c协议和平台已经分开了,可通用层调用的仍然是 STM32F0。下一步自然会遇到一个问题:如果再增加一种平台,ws2811.c 应该怎么选?
五、用宏修正对外的 RGB 颜色顺序
在考虑平台选择之前,我们又整理了一处协议层的使用体验。
当前灯带实际接收的是 BRG 顺序,可调用者平时还是习惯写 RGB。如果要求应用层自己记住每个字节该放在哪里,颜色顺序就会泄漏到所有调用处。
于是增加一个按常规 RGB 参数传入的宏:
#define WS2811_COLOR(red, green, blue) \
((((uint32_t)(blue)) << 16) | \
(((uint32_t)(red)) << 8) | \
((uint32_t)(green)))应用层仍然按红、绿、蓝的顺序写:
WS2811_COLOR(0x20, 0x00, 0x00)宏在预处理阶段展开,本身不会分配一块对象,也不会多出一次函数调用。这里的颜色打包发生在发送时序循环之外;常量参数还可以在编译阶段直接折叠,所以不会改变已经校准的 bit 发送路径。
这一步想得到的结果很简单:应用层使用正常的 RGB 语义,协议层负责把它整理成当前器件需要的 24 bit 数据。
六、跑通后再思考:通用层怎么选择平台
分层后的版本已经完成编译和实机验证,接下来才开始考虑平台选择。
最直接的办法是在 ws2811_init() 里判断硬件类型:
if (platform == STM32F0) {
ws2811_stm32f0_init(...);
} else if (platform == OTHER_MCU) {
other_mcu_init(...);
}这样确实能跑,但每增加一个平台,都要回头修改 ws2811.c。通用层需要认识所有平台名称,也要知道每个平台对应哪些函数。
我们想得到的结果是:
增加平台时新增平台文件
尽量不修改 ws2811.c既然 ws2811.c 不应该自己列举平台,它就需要从外部得到“这一块硬件该调用哪些操作”。
问题走到这里,ops 才有了实际用途。
七、从已有跨层操作中提取 ops
操作表的灵魂不在函数指针语法,而在于把已经稳定下来的跨层约定收成一份接口。
前面固定连接跑通时,协议层实际需要平台完成这些事情:
初始化硬件
发送完整一帧
退出当前硬件状态于是把已有跨层操作提取出来:
struct ws2811_ops {
int (*init)(const void *platform_config);
int (*write_frame)(
const void *platform_config,
const uint32_t *frame,
uint32_t frame_ic_count
);
int (*deinit)(const void *platform_config);
};这里没有把接口继续拆成:
set_high()
set_low()
send_bit()如果通用层逐 bit 调用平台,时序细节又会回到通用层,函数指针跳转还会进入最敏感的指令路径。前面好不容易稳定下来的 24 bit 发送和关中断流程也会被打散。
所以这次选择整帧级的 write_frame()。协议层校验完数据,把完整帧交出去;平台层从关中断开始,一直负责到最后的 RESET。
ws2811.c 的固定平台调用随之改成:
return me->config->ops->write_frame(
me->config->platform_config,
frame,
frame_ic_count
);通用层从此只认识 struct ws2811_ops。具体是哪一个平台,由外部配置传进来。
八、接入 ops 后,static 的归属也跟着变化
调用关系变化以后,可见性要重新检查。
接入 ops 以前:
ws2811.c 直接调用 STM32F0 平台入口因此平台入口不能是 static。
接入 ops 以后:
ws2811.c 调用 ops 中的函数指针
外部只需要拿到 STM32F0 的操作表这时真正需要公开的是操作表:
extern const struct ws2811_ops ws2811_stm32f0_ops;具体实现可以重新收回 ws2811_stm32f0.c:
static int ws2811_stm32f0_init(...);
static int ws2811_stm32f0_write_frame(...);
static int ws2811_stm32f0_deinit(...);然后在同一个 .c 文件里把它们装进操作表:
const struct ws2811_ops ws2811_stm32f0_ops = {
ws2811_stm32f0_init,
ws2811_stm32f0_write_frame,
ws2811_stm32f0_deinit
};这里只在头文件写 extern 还不够。extern 是声明,告诉其他文件这张表存在;上面的代码才是定义,真正为操作表提供实体。
我原本想使用指定成员初始化:
const struct ws2811_ops ws2811_stm32f0_ops = {
.init = ws2811_stm32f0_init,
.write_frame = ws2811_stm32f0_write_frame,
.deinit = ws2811_stm32f0_deinit
};当前 Keil 工程使用 ARMCC5,它不接受这里的写法,最后改成按照结构体成员顺序初始化。
调整完成以后,工程达到 0 Error(s), 0 Warning(s),随后再次烧录并完成实机验证。
九、最后再看完整调用链
现在主函数选择 STM32F0 平台:
static const struct ws2811_stm32f0_config platform_config = {
GPIOA,
GPIO_Pin_4
};
static const struct ws2811_config strip_config = {
8,
&ws2811_stm32f0_ops,
&platform_config
};
static struct ws2811 strip;应用层只调用通用接口:
ws2811_init(&strip, &strip_config);
ws2811_write(&strip, frame, 8);发送一帧时的调用链如下:
sequenceDiagram
participant Main as main
participant Core as ws2811.c
participant Ops as ws2811_stm32f0_ops
participant F0 as ws2811_stm32f0.c
participant GPIO as STM32F0 GPIO
participant LED as WS2811
Main->>Core: ws2811_write(&strip, frame, 8)
Core->>Core: 检查对象、帧长度和 24-bit 数据
Core->>Ops: ops->write_frame(...)
Ops->>F0: ws2811_stm32f0_write_frame()
F0->>F0: 保存 PRIMASK 并关闭中断
loop 每颗级联 IC
F0->>GPIO: ws2811_stm32f0_send_24bit()
GPIO->>LED: 发送 24-bit 波形
end
F0->>F0: 恢复原来的中断状态
F0->>LED: 保持 280 μs RESET 低电平这条调用链不是先画出来,再要求代码去配合它。
先有混在一起的驱动函数;接着根据变化来源重新判断归属;然后新建 STM32F0 平台文件,把 GPIO、RESET、24 bit 和整帧发送迁走;固定调用实机跑通以后,才发现平台选择仍然写死;最后从已有跨层操作中提取 ops,替换固定依赖。
现在真正验证过的平台只有 STM32F0。以后增加新硬件时,新建对应的 .c/.h 平台层并提供另一张 struct ws2811_ops。到那时再看 write_frame() 的粒度、平台配置和同步发送接口是否还够用。
上一篇我说当前没必要上 ops,当时那句话没有错。这一次也不是为了让代码看起来像某种框架。变化真的出现以后,先把变化移出去,再从已经跑通的调用关系里提取稳定接口,操作表才有意义。
至于第二个平台接进来以后,这张表会不会被改得面目全非,等下篇文章展示操作。