
引言
在嵌入式产品开发中,同一个功能模块(比如 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_write 和 q_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,
®, 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。关键寄存器包括:
| 寄存器 | 地址 | 功能 |
|---|---|---|
| REG00 | 0x00 | 输入电流限制 |
| REG02 | 0x02 | 充电电流设置 |
| REG04 | 0x04 | 充电截止电压 |
| REG05 | 0x05 | 充电定时器控制 |
| REG07 | 0x07 | 杂项控制(含 BATFET 控制) |
| REG08 | 0x08 | 系统状态 |
| REG09 | 0x09 | 故障状态 |
| REG0B | 0x0B | 芯片 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;
}
设备端口层做了两件事:
- 持有 q_device 句柄:
pmic_i2c_dev在初始化时绑定到"i2c_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 直接调用 BSPsgm41511.c中的sgm41511_write_reg对外不可见——适配层只能通过头文件声明的公开函数访问- 即使两个不同文件中的
static函数碰巧同名,编译器/链接器也不会产生冲突
这种隔离不依赖命名约定或代码审查,而是由编译器强制保证的。即使有人不小心 #include 了错误的头文件,也调用不到其他文件的 static 函数。
与 q_device ops 的配合
static 提供的是编译期隔离——防止跨层直接调用。而 q_device 的 ops 函数指针表提供的是运行时分发——同一个 q_device_write 调用,根据设备句柄的不同,可以分发到不同 BSP 的实现。
两者配合,实现了完整的分层解耦:
- 编译期:
static保证每层的内部实现对外不可见 - 运行时:q_device 通过函数指针实现多态分发
- 链接期:
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 平台。对于需要支持多芯片型号、多硬件版本的产品线来说,这种分层架构带来的维护便利远大于它引入的少量间接调用开销。


