上次分享了关于封装的心得和体会,这次上点稍微复杂的。

兆鸣老师的书里依旧有,但是我也刚学没多久,不一定理解得透。我把教材做成了 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.h

ws2811_stm32f0.h 先声明平台需要的配置:

struct ws2811_stm32f0_config {
    GPIO_TypeDef *port;
    uint16_t pin;
};

然后按照刚才确定的归属迁移函数。

1. 迁移平台配置检查

端口是不是 STM32F0 支持的 GPIOAGPIOBGPIOF,只有这个平台需要知道:

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 发送函数整体搬入平台文件,保留局部 portpin 和原来的 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,当时那句话没有错。这一次也不是为了让代码看起来像某种框架。变化真的出现以后,先把变化移出去,再从已经跑通的调用关系里提取稳定接口,操作表才有意义。

至于第二个平台接进来以后,这张表会不会被改得面目全非,等下篇文章展示操作。