返回博客
嵌入式分层驱动架构实践 —— 以 SGM41511 PMIC 驱动与 q_device 统一 API 适配为例

嵌入式分层驱动架构实践 —— 以 SGM41511 PMIC 驱动与 q_device 统一 API 适配为例

引言

在嵌入式产品开发中,同一个功能模块(比如 PMIC 电源管理芯片)往往会因为供应链、成本、硬件迭代等原因,在不同产品版本上使用不同型号的芯片。如果每换一颗芯片就要大面积改上层业务代码,维护成本会迅速失控。

本文以一个真实项目中的 PMIC 驱动为例,分析其如何通过 q_device 统一设备框架 实现分层解耦:底层总线(I2C)、芯片驱动(SGM41511)、适配层(pmic)各司其职,上层业务代码不关心具体用的哪颗芯片、走的哪条总线。

SGM41511 是圣邦微电子(SGMICRO)推出的一款线性充电管理芯片,支持 I2C 接口配置,广泛应用于可穿戴设备、IoT 终端等低功耗产品。在本项目中,它是多个候选 PMIC 型号之一,通过统一的驱动架构实现无缝切换。

整个调用链路如下:

上层业务
   ↓
pmic.c(统一 PMIC API,宏选择芯片型号)
   ↓
sgm41511.c(SGM41511 芯片驱动,寄存器级操作)
   ↓
pmic_device_port.c(设备端口适配,桥接 q_device)
   ↓
q_device_write/read(统一设备框架,函数指针分发)
   ↓
bsp_i2c.c(BSP 层,I2C 总线驱动实现)
   ↓
I2C 硬件外设

整体架构总览

整套驱动分为四层,自底向上职责清晰:

层级文件职责
BSP 层bsp_i2c.c封装平台 I2C HAL,实现总线读写操作
设备框架q_device.c / q_device.h统一设备模型,提供 register/find/read/write/ctrl 等标准接口
设备端口pmic_device_port.c桥接层,将 PMIC 的读写操作转换为 q_device 调用
模块层pmic.c + sgm41511.c芯片驱动 + 统一 PMIC API,通过宏适配不同芯片型号

每一层只依赖相邻的下一层,不允许跨层调用。这种分层的好处是:

  • 换总线:如果某颗 PMIC 不走 I2C 而是走 SPI 或厂商自定义协议,只需替换 BSP 层,设备驱动和上层不变
  • 换芯片:如果 PMIC 从 SGM41511 换成 ETA4662 或 SY6103,只需修改 pmic.c 的宏定义,上层业务不变
  • 换平台:如果从 nRF52 换到 PHY6222 或其他 MCU,只需重写 BSP 层的 I2C HAL 对接

q_device 统一设备框架

q_device 是这套架构的核心,它借鉴了 Linux 内核的设备模型思想,用函数指针表(ops)来抽象所有硬件设备的操作接口。

设备模型

每个硬件设备被抽象为一个 q_device_t 结构体:

struct q_device
{
    const char *name;                  // 设备名称,用于查找
    const struct q_device_ops *dops;   // 操作函数指针表
    void *owner;                       // 所属任务(RTOS 场景)
    void *argv;                        // 私有数据
    int data;                          // 附加数据
    struct q_device *next;             // 链表指针
};

操作函数指针表定义了统一的接口:

struct q_device_ops
{
    int (*init)   (q_device_t *dev);
    int (*uninit) (q_device_t *dev);
    int (*open)   (q_device_t *dev);
    int (*close)  (q_device_t *dev);
    int (*read)   (q_device_t *dev, int pos, const void *buffer, int size);
    int (*write)  (q_device_t *dev, int pos, const void *buffer, int size);
    int (*control)(q_device_t *dev, int cmd, void *args);
    int (*config) (q_device_t *dev, void *args, void *var);
    int (*register_callback)(q_device_t *dev, int pos, void *callback);
};

无论是 I2C、SPI、UART 还是 GPIO,对上层来说都是一样的 q_device_read / q_device_write 调用。这就是抽象的力量——上层不需要知道数据是通过哪条总线传输的。

注册与查找

设备注册到一个全局链表中:

// BSP 层注册设备
q_device_register(&bsp_i2c_list[i].dev);

// 上层通过名称查找设备
q_device_t *dev = q_device_find("i2c_2");

q_device_register 内部会检查设备名是否重复,防止同名设备冲突。q_device_find 遍历链表按名称匹配,返回设备句柄供后续操作使用。

统一调用接口

q_device_writeq_device_read 的实现非常简洁——就是做空指针校验后调用 ops 中注册的函数:

int q_device_write(q_device_t *dev, int pos, const void *buffer, int size)
{
    if (dev)
    {
        if (dev->dops->write)
        {
            return dev->dops->write(dev, pos, buffer, size);
        }
        else
        {
            return RESULT_WRITE_POINTER_NULL_ERROR;
        }
    }
    else
    {
        return RESULT_DEV_POINTER_NULL_ERROR;
    }
}

这就是经典的 策略模式(Strategy Pattern)在 C 语言中的实现——通过函数指针实现运行时多态。调用 q_device_write 时,实际执行的是 BSP 层注册的具体写操作函数。

自动初始化:initcall 分级机制

q_device 还提供了一套类似 Linux 内核的 initcall 机制,利用编译器的 __attribute__((section)) 将初始化函数放到指定的链接段中,启动时按级别顺序调用:

#define pure_initcall(fn)       __define_initcall(fn, 0)  // 系统时钟初始化
#define fs_initcall(fn)         __define_initcall(fn, 1)  // tick 和调试接口
#define device_initcall(fn)     __define_initcall(fn, 2)  // 驱动初始化
#define late_initcall(fn)       __define_initcall(fn, 3)  // 传感器初始化

BSP 层的 I2C 设备就是通过 device_initcall 自动注册的:

static void bsp_i2c_register(void)
{
    for (int i = 0; i < array_size(bsp_i2c_list); i++) {
        bsp_i2c_list[i].dev.name = bsp_i2c_list[i].name;
        bsp_i2c_list[i].dev.dops = &i2c_ops;
        q_device_register(&bsp_i2c_list[i].dev);
    }
}

device_initcall(bsp_i2c_register);

系统启动时调用 do_init_call(),即可按 level 0 → 1 → 2 的顺序完成所有设备注册,无需手动在 main() 中逐个调用初始化函数。

BSP 层:I2C 总线驱动实现

BSP 层负责封装平台相关的 I2C 硬件操作。在 nRF52 平台上,底层使用 Nordic 的 TWI(Two-Wire Interface,即 I2C)HAL 库。BSP 层将其包装成 q_device 的标准 ops 接口,屏蔽平台差异。

I2C 设备结构

每个 I2C 总线实例用一个结构体描述,包含引脚配置和 q_device 节点:

struct BSP_I2C
{
    const char *name;
    bool lock;
    nrfx_twi_t twi_instance;
    uint32_t scl_pin;
    uint32_t sda_pin;
    q_device_t dev;
};

static struct BSP_I2C bsp_i2c_list[] =
{
    {
        .name = "i2c_2",
        .lock = false,
        .twi_instance = NRFX_TWI_INSTANCE(1),
        .scl_pin = NRF_GPIO_PIN_MAP(0, 27),
        .sda_pin = NRF_GPIO_PIN_MAP(0, 26),
    },
};

数据包结构

q_device 框架定义了通用的 I2C 数据包结构,上层通过填充这个结构体来描述一次 I2C 事务:

struct i2c_package
{
    uint8_t slave_addr;       // 7-bit 从机地址
    uint16_t reg_addr;        // 寄存器地址
    uint8_t *write_buff;      // 写数据缓冲区
    uint16_t write_length;    // 写数据长度
    uint8_t *read_buff;       // 读数据缓冲区
    uint16_t read_length;     // 读数据长度
};

这个结构体是 BSP 层和设备端口层之间的"契约"——双方都按这个格式打包/解包数据。

读写操作实现

BSP 层的 I2C 写操作:先发送寄存器地址,再发送数据,是标准的 I2C 寄存器写入流程:

static int bsp_i2c_write(q_device_t *dev, int pos, const void *buffer, int size)
{
    struct i2c_package *pack = (struct i2c_package *)buffer;
    if (pack == NULL) return RESULT_I2C_CONFIG_NULL_ERR;

    struct BSP_I2C *bsp = &bsp_i2c_list[0];  // 根据 dev 查找对应实例
    if (!bsp->lock) return RESULT_I2C_DEV_UNOPENED_ERR;

    // 组装 I2C 写帧:[reg_addr][data...]
    uint8_t tx_buf[16];
    tx_buf[0] = (uint8_t)pack->reg_addr;
    memcpy(&tx_buf[1], pack->write_buff, pack->write_length);

    nrfx_err_t err = nrfx_twi_tx(&bsp->twi_instance,
                                   pack->slave_addr,
                                   tx_buf,
                                   pack->write_length + 1,
                                   false);
    if (err != NRFX_SUCCESS) return RESULT_I2C_SEND_ERR;
    return RESULT_OK;
}

I2C 读操作分两步:先写寄存器地址(不发 STOP),再读取数据:

static int bsp_i2c_read(q_device_t *dev, int pos, const void *buffer, int size)
{
    struct i2c_package *pack = (struct i2c_package *)buffer;
    if (pack == NULL) return RESULT_I2C_CONFIG_NULL_ERR;

    struct BSP_I2C *bsp = &bsp_i2c_list[0];
    if (!bsp->lock) return RESULT_I2C_DEV_UNOPENED_ERR;

    uint8_t reg = (uint8_t)pack->reg_addr;

    // 第一步:写寄存器地址(no stop)
    nrfx_err_t err = nrfx_twi_tx(&bsp->twi_instance,
                                   pack->slave_addr,
                                   &reg, 1, true);
    if (err != NRFX_SUCCESS) return RESULT_I2C_SEND_ERR;

    // 第二步:读取数据
    err = nrfx_twi_rx(&bsp->twi_instance,
                       pack->slave_addr,
                       pack->read_buff,
                       pack->read_length);
    if (err != NRFX_SUCCESS) return RESULT_I2C_READ_ERR;
    return RESULT_OK;
}

注册为 q_device

BSP 层将这些底层操作包装成 q_device 的 ops,并通过 initcall 自动注册:

static struct q_device_ops i2c_ops =
{
    .init  = bsp_i2c_init_dev,
    .open  = bsp_i2c_open,
    .close = bsp_i2c_close,
    .write = bsp_i2c_write,
    .read  = bsp_i2c_read,
};

device_initcall(bsp_i2c_register);

注册完成后,任何上层模块通过 q_device_find("i2c_2") 即可获取这个 I2C 总线设备的句柄,然后通过 q_device_write / q_device_read 进行操作——完全不需要知道底层用的是 nRF52 的 TWI 还是 STM32 的 HAL_I2C。

设备层:SGM41511 驱动封装

sgm41511.c 是芯片级别的驱动,负责 SGM41511 的寄存器配置和状态读取。它不关心底层是用什么平台的 I2C 外设通信的。

寄存器定义

SGM41511 通过 I2C 接口访问内部寄存器,7-bit 从机地址为 0x6B。关键寄存器包括:

寄存器地址功能
REG000x00输入电流限制
REG020x02充电电流设置
REG040x04充电截止电压
REG050x05充电定时器控制
REG070x07杂项控制(含 BATFET 控制)
REG080x08系统状态
REG090x09故障状态
REG0B0x0B芯片 ID / 版本号

底层读写封装

sgm41511.c 内部定义了 static 的寄存器读写辅助函数,通过设备端口层间接访问 I2C 总线:

#include "sgm41511.h"
#include "pmic_device_port.h"

#define SGM41511_REG0B_DEVICE_ID  0x02  // REG0B[6:3] 期望值

static bool sgm41511_write_reg(uint8_t reg, uint8_t data)
{
    return pmic_i2c_write(reg, data, 1);
}

static bool sgm41511_read_reg(uint8_t reg, uint8_t *data)
{
    return pmic_i2c_read(reg, data, 1);
}

然后基于这两个底层读写函数,实现芯片级功能:

// 读取芯片 ID
uint8_t sgm41511_read_id(void)
{
    uint8_t data = 0;
    sgm41511_read_reg(0x0B, &data);
    return (data >> 3) & 0x0F;  // bits[6:3] = Part Number
}

// 读取充电状态
uint8_t sgm41511_get_charge_status(void)
{
    uint8_t data = 0;
    sgm41511_read_reg(0x08, &data);
    uint8_t chrg_stat = (data >> 3) & 0x03;  // bits[4:3]
    // 0: Not Charging, 1: Pre-charge, 2: Fast Charging, 3: Charge Done
    return chrg_stat;
}

// 初始化:配置充电参数
ret_code_t sgm41511_init(void)
{
    if (sgm41511_read_id() != SGM41511_REG0B_DEVICE_ID) {
        return NRF_ERROR_TIMEOUT;
    }

    // REG00: 输入电流限制 500mA
    sgm41511_write_reg(0x00, 0x08);
    // REG02: 充电电流 480mA
    sgm41511_write_reg(0x02, 0x18);
    // REG04: 充电截止电压 4.2V
    sgm41511_write_reg(0x04, 0x58);
    // REG05: 充电安全定时器 8h,使能定时器
    sgm41511_write_reg(0x05, 0x13);

    return NRF_SUCCESS;
}

// 进入 ship mode(关断 BATFET,极低功耗)
void sgm41511_set_shipmode(void)
{
    uint8_t data = 0;
    sgm41511_read_reg(0x07, &data);
    data |= 0x20;  // 置位 BATFET_DIS
    sgm41511_write_reg(0x07, data);
}

设备端口层如何桥接

pmic_device_port.c 是连接设备驱动和 q_device 框架的桥梁。它在初始化时通过 q_device_find 获取 I2C 总线设备的句柄,然后提供封装好的读写函数:

static q_device_t *pmic_i2c_dev = NULL;

static struct i2c_package i2c_pack = {
    .slave_addr = 0x6B,       // SGM41511 I2C 地址
    .write_length = 1,
    .read_length = 1,
};

void pmic_i2c_init(void)
{
    pmic_i2c_dev = q_device_find("i2c_2");
    q_device_assert(pmic_i2c_dev);
}

bool pmic_i2c_write(uint8_t reg_addr, uint8_t data, uint8_t length)
{
    i2c_pack.reg_addr = reg_addr;
    i2c_pack.write_length = length;
    i2c_pack.write_buff = &data;
    if (q_device_write(pmic_i2c_dev, 0, &i2c_pack, 0) == RESULT_OK)
    {
        return true;
    }
    return false;
}

bool pmic_i2c_read(uint8_t reg_addr, uint8_t *data, uint8_t length)
{
    i2c_pack.reg_addr = reg_addr;
    i2c_pack.read_length = length;
    i2c_pack.read_buff = data;
    if (q_device_read(pmic_i2c_dev, 0, &i2c_pack, 0) == RESULT_OK)
    {
        return true;
    }
    return false;
}

设备端口层做了两件事:

  1. 持有 q_device 句柄pmic_i2c_dev 在初始化时绑定到 "i2c_2" 设备,后续所有操作都通过这个句柄
  2. 封装数据打包:将简单的 (reg, data, length) 参数填入 i2c_package 结构体,再转交给 q_device_write / q_device_read

这一层的存在让芯片驱动(sgm41511.c)可以用最简洁的接口完成寄存器操作,不需要关心 q_device 的数据包格式。

适配层:PMIC 统一 API

pmic.c 是最上层的适配文件,通过编译期宏 PMIC_DEVIECE_TYPE 选择具体的芯片驱动,对外提供统一的 PMIC 接口:

#if (PMIC_DEVIECE_TYPE == 0)    // SY6103
#include "sy6103.h"
#elif (PMIC_DEVIECE_TYPE == 1)  // ETA4662
#include "eta4662.h"
#elif (PMIC_DEVIECE_TYPE == 2)  // SGM41511
#include "sgm41511.h"
#endif

每个 API 函数内部都用 #if / #elif 分支调用对应芯片的函数:

void pmic_init(void)
{
#if (PMIC_DEVIECE_TYPE == 0)
    sy6103_init();
#elif (PMIC_DEVIECE_TYPE == 1)
    eta4662_init();
#elif (PMIC_DEVIECE_TYPE == 2)
    sgm41511_init();
#endif
}

enum pmic_charge_status pmic_get_charge_status(void)
{
#if (PMIC_DEVIECE_TYPE == 0)
    return (enum pmic_charge_status)sy6103_charge_get_status();
#elif (PMIC_DEVIECE_TYPE == 1)
    return (enum pmic_charge_status)eta4662_charge_get_status();
#elif (PMIC_DEVIECE_TYPE == 2)
    return (enum pmic_charge_status)sgm41511_get_charge_status();
#endif
}

上层业务代码只需调用 pmic_init()pmic_get_charge_status() 等统一接口,完全不需要知道底层用的是哪颗芯片。切换芯片只需改一个宏定义,重新编译即可。

这套适配层同时也处理了不同硬件版本之间的差异。比如某些硬件版本需要先打开 LDO 电源再操作 PMIC:

bool pmic_get_id(uint8_t *data)
{
#if (PMIC_DEVIECE_TYPE == 0)
  #if (HARDWARE_413_ENABLED == 1)
    ldo_pmic_power_on();
    delay_ms(20);
    sy6103_get_chip_id(data);
    ldo_pmic_power_off();
    return true;
  #else
    return sy6103_get_chip_id(data);
  #endif
#elif (PMIC_DEVIECE_TYPE == 2)
    *data = sgm41511_read_id();
    return true;
#endif
}

static 关键字与分层隔离

这套分层架构中,static 关键字扮演了重要的隔离角色。每一层都有自己的 static 内部函数,对外完全不可见。

各层的 static 函数

层级static 函数作用
BSP 层bsp_i2c_write() / bsp_i2c_read()封装平台 I2C HAL 调用
设备层sgm41511_write_reg() / sgm41511_read_reg()封装寄存器级读写
设备端口(非 static,但文件内 i2c_pack 是 static)桥接 q_device 和芯片驱动

为什么 static 是关键

C 语言中,static 函数的作用域限定在定义它的源文件内(translation unit)。这意味着:

  • bsp_i2c.c 中的 bsp_i2c_write 对外不可见——上层无法绕过 q_device 直接调用 BSP
  • sgm41511.c 中的 sgm41511_write_reg 对外不可见——适配层只能通过头文件声明的公开函数访问
  • 即使两个不同文件中的 static 函数碰巧同名,编译器/链接器也不会产生冲突

这种隔离不依赖命名约定或代码审查,而是由编译器强制保证的。即使有人不小心 #include 了错误的头文件,也调用不到其他文件的 static 函数。

与 q_device ops 的配合

static 提供的是编译期隔离——防止跨层直接调用。而 q_device 的 ops 函数指针表提供的是运行时分发——同一个 q_device_write 调用,根据设备句柄的不同,可以分发到不同 BSP 的实现。

两者配合,实现了完整的分层解耦:

  1. 编译期static 保证每层的内部实现对外不可见
  2. 运行时:q_device 通过函数指针实现多态分发
  3. 链接期static 函数不导出符号,避免同名冲突

总结

回顾整个 PMIC 驱动的分层架构,可以画出完整的调用链:

pmic_get_charge_status()                     ← 统一 PMIC API
    ↓
sgm41511_get_charge_status()                    ← 芯片驱动(sgm41511.c)
    ↓
sgm41511_read_reg() [sgm41511.c static]         ← 寄存器读写封装
    ↓
pmic_i2c_read()                                 ← 设备端口(pmic_device_port.c)
    ↓
q_device_read() → dev->dops->read()             ← q_device 框架分发
    ↓
bsp_i2c_read() [bsp_i2c.c static]              ← BSP I2C 实现
    ↓
nrfx_twi_tx/rx()                                ← 平台 HAL

这套架构的优点

  • 芯片可替换:通过宏切换 PMIC 型号(SY6103 / ETA4662 / SGM41511),上层零改动
  • 总线可替换:q_device 抽象了总线细节,如果某颗芯片用 SPI 或自定义协议通信,只需增加对应的 BSP 实现
  • 平台可移植:BSP 层封装了平台相关的 I2C HAL(nRF52 用 nrfx_twi,STM32 用 HAL_I2C),换 MCU 只改这一层
  • 自动初始化:initcall 机制避免了手动管理初始化顺序,新增设备只需一行 device_initcall
  • static 强隔离:各层内部实现对外不可见,从编译层面杜绝了跨层调用

需要注意的地方

  • 宏条件编译可读性差pmic.c 中大量的 #if / #elif 嵌套,随着支持的芯片型号增多会越来越难维护。可以考虑用函数指针表替代宏分支,实现运行时的芯片选择
  • 错误传递链较长:从 BSP 的 nrfx_err_t → q_device 的 enum result_state → 设备端口的 bool → 芯片驱动的 ret_code_t,每过一层都有一次错误码转换,存在信息丢失风险
  • I2C 地址硬编码:设备端口层中 slave_addr = 0x6B 是写死的,如果同一条 I2C 总线上挂多个 PMIC(不同地址),需要扩展数据结构
  • 设备查找效率q_device_find 是链表线性查找,设备数量多时效率偏低(嵌入式场景通常设备不多,影响不大)

总的来说,这套 q_device 框架是一个轻量级但够用的嵌入式设备抽象层。它用最小的代价(一个链表 + 函数指针表)实现了设备管理和分层隔离,适合资源受限的 MCU 平台。对于需要支持多芯片型号、多硬件版本的产品线来说,这种分层架构带来的维护便利远大于它引入的少量间接调用开销。