前言

如果你长期写 STM32、ESP32 或 Linux 驱动,第一次接触 Zephyr RTOS 时,最容易卡住的地方通常不是线程、信号量这些传统 RTOS 概念,而是三个看起来“有点绕”的基础设施:Devicetree、Kconfig 和驱动模型。很多人会问:为什么点一个 LED、读一个 I2C 传感器,不能像裸机工程那样直接写寄存器地址?为什么配置宏要分散在 prj.confKconfig*.overlay*.yaml 和生成目录里?

原因很简单:Zephyr 面向的不是单个芯片或单块板子,而是“同一套应用可以在不同板级硬件上复用”。它把硬件描述、功能裁剪、驱动实例化、应用逻辑拆开,让应用尽量不关心底层板子的管脚、总线地址和外设差异。这个思路非常适合产品化嵌入式开发:今天样机用 nRF52,明天量产版换 STM32;今天传感器挂在 i2c0,明天板子改版挪到 i2c1;应用层最好只写一次。

本文不做“概念堆砌”,而是以一个虚构但贴近真实项目的 I2C 温湿度传感器 xyz123 为例,完整走一遍 Zephyr 驱动开发链路:如何写 Devicetree overlay,如何写 binding,如何通过 Kconfig 打开驱动,如何用 DEVICE_DT_INST_DEFINE() 生成设备实例,如何在应用中调用标准 sensor API,最后再给出调试和移植时最常见的坑。

Zephyr 驱动构建与实例化流程

一、为什么 Zephyr 要把硬件描述放到 Devicetree

在传统裸机工程里,硬件信息经常散落在 C 代码中:I2C 地址写成宏,GPIO 管脚写成宏,时钟频率写在 board.h,中断优先级写在初始化函数里。项目小的时候这很直观;项目一旦有多块板、多种传感器、多套 SKU,维护成本会很快上升。

Zephyr 借鉴 Linux 的 Devicetree 思路,把“板上有什么硬件、硬件挂在哪里、默认参数是什么”写成树状描述。比如一个 I2C 传感器可以描述为:它挂在 i2c1 控制器下面,地址是 0x44,中断脚连接到 gpio0 的第 12 脚,采样周期默认 1000 ms。驱动代码不直接写死这些值,而是在编译期从 Devicetree 生成的头文件中取。

这种方式有三个直接好处:

  1. 应用和硬件解耦:应用只拿一个设备指针,不需要知道传感器到底在 i2c0 还是 i2c1
  2. 同一驱动支持多个实例:一块板上可以挂两个相同型号的传感器,只要 Devicetree 写两个节点即可。
  3. 编译期发现错误:地址、管脚、兼容字符串写错,很多问题会在构建阶段暴露,而不是运行时才发现。

一个最简 overlay 可能是这样:

&i2c1 {
    status = "okay";
    clock-frequency = <I2C_BITRATE_STANDARD>;

    xyz123@44 {
        compatible = "demo,xyz123";
        reg = <0x44>;
        label = "XYZ123";
        int-gpios = <&gpio0 12 GPIO_ACTIVE_LOW>;
        sample-period-ms = <1000>;
    };
};

注意这里的 compatible = "demo,xyz123" 非常关键。Zephyr 不是靠节点名字 xyz123@44 找驱动,而是靠 compatible 字符串把 Devicetree 节点和驱动的实例化宏关联起来。

二、Kconfig 解决的是“要不要编译”和“编译哪些特性”

Devicetree 描述硬件“存在什么”,Kconfig 描述软件“启用什么”。两者经常一起出现,但职责不同。比如板子上可以有 xyz123 传感器节点,但你仍然可以通过 Kconfig 决定是否编译该驱动、是否打开日志、是否启用中断模式。

一个驱动的 Kconfig 通常长这样:

menuconfig XYZ123
    bool "XYZ123 temperature and humidity sensor"
    default y
    depends on DT_HAS_DEMO_XYZ123_ENABLED
    select I2C
    select SENSOR
    help
      Enable driver for the XYZ123 I2C temperature and humidity sensor.

if XYZ123

config XYZ123_TRIGGER
    bool "Enable XYZ123 interrupt trigger support"
    depends on GPIO
    help
      Enable data-ready interrupt support for XYZ123.

config XYZ123_INIT_PRIORITY
    int "XYZ123 init priority"
    default 90

endif

这里 DT_HAS_DEMO_XYZ123_ENABLED 是 Zephyr 根据 binding 和 Devicetree 自动生成的宏,含义是“是否存在 enabled 状态的 demo,xyz123 节点”。这样做的好处是:没有硬件节点时,驱动默认不会被莫名其妙编译进来;有硬件节点时,驱动可以自动打开。

在应用工程的 prj.conf 里,我们只需要写:

CONFIG_I2C=y
CONFIG_SENSOR=y
CONFIG_XYZ123=y
CONFIG_LOG=y
CONFIG_XYZ123_TRIGGER=n

实际项目里,我建议把 prj.conf 当作“产品功能配置”,不要把板级管脚、I2C 地址塞进去。管脚和地址属于 Devicetree;是否启用日志、是否启用 Shell、是否打开某个驱动特性,才属于 Kconfig。

(第一部分完,约2100字)

三、工程目录怎么组织才不容易乱

Zephyr 支持多种工程组织方式:可以把驱动直接放在应用工程里,也可以作为 out-of-tree module 独立维护。初学阶段建议先放在应用工程里,确认流程跑通后再拆成模块。一个清晰的最小结构如下:

zephyr-xyz123-demo/
├── CMakeLists.txt
├── prj.conf
├── boards/
│   └── nucleo_f446re.overlay
├── dts/
│   └── bindings/
│       └── sensor/
│           └── demo,xyz123.yaml
├── drivers/
│   └── sensor/
│       └── xyz123/
│           ├── CMakeLists.txt
│           ├── Kconfig
│           └── xyz123.c
└── src/
    └── main.c

顶层 CMakeLists.txt 负责把应用源文件和自定义驱动目录加入构建:

cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(zephyr_xyz123_demo)

target_sources(app PRIVATE src/main.c)
add_subdirectory(drivers/sensor/xyz123)

驱动目录中的 CMakeLists.txt 则根据 Kconfig 决定是否编译源文件:

zephyr_library()
zephyr_library_sources_ifdef(CONFIG_XYZ123 xyz123.c)

如果驱动未来会变成公司内部通用模块,就可以把 drivers/dts/bindings/Kconfig 抽出去,通过 ZEPHYR_EXTRA_MODULES 引入。不要一上来就追求“模块化最佳实践”,先把最小闭环跑通,定位问题会轻松很多。

四、binding YAML:让 Devicetree 属性变成可校验的接口

Devicetree overlay 写了 sample-period-msint-gpios 等属性,Zephyr 需要知道这些属性的类型和含义,这就要靠 binding YAML。我们的 demo,xyz123.yaml 可以这样写:

description: Demo XYZ123 temperature and humidity sensor

compatible: "demo,xyz123"

include: [sensor-device.yaml, i2c-device.yaml]

properties:
  int-gpios:
    type: phandle-array
    required: false
    description: Optional data-ready interrupt GPIO

  sample-period-ms:
    type: int
    default: 1000
    description: Default sampling period in milliseconds

include: i2c-device.yaml 表示该设备遵循 I2C 子节点的基本约定,例如必须有 reg 属性。sensor-device.yaml 则让它符合 sensor 类设备的公共属性。这样写完后,如果 overlay 中把 sample-period-ms 写成字符串,构建阶段就会报错;如果把 compatible 写错,驱动实例也不会生成。

很多 Zephyr 新手会忽略 binding,直接在 C 代码里尝试 DT_PROP() 读取属性,然后发现宏不存在。排查这类问题时,第一步永远是确认 compatible 是否一致:

  • overlay 中写的是 compatible = "demo,xyz123";
  • YAML 文件名通常写成 demo,xyz123.yaml
  • 驱动中使用的实例宏会转成 DT_DRV_COMPAT demo_xyz123

逗号和连字符在 C 宏里会转换为下划线,这是一个常见细节。

五、驱动核心:config、data 与 DEVICE_DT_INST_DEFINE

Zephyr 驱动通常把只读配置和运行时状态分开:

  • config:来自 Devicetree 的总线、地址、GPIO、默认参数,通常放 const
  • data:运行时采样值、锁、状态标志,通常每个设备实例一份。

下面是一份可作为起点的 xyz123.c。真实传感器的寄存器可能不同,但结构和 Zephyr 驱动写法基本一致。

#define DT_DRV_COMPAT demo_xyz123

#include <zephyr/device.h>
#include <zephyr/drivers/i2c.h>
#include <zephyr/drivers/sensor.h>
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>

LOG_MODULE_REGISTER(xyz123, CONFIG_SENSOR_LOG_LEVEL);

#define XYZ123_REG_TEMP_MSB   0x00
#define XYZ123_REG_HUM_MSB    0x02
#define XYZ123_REG_CONFIG     0x10
#define XYZ123_CMD_MEASURE    0xA5

struct xyz123_config {
    struct i2c_dt_spec bus;
    uint32_t sample_period_ms;
};

struct xyz123_data {
    int32_t temp_milli_c;
    int32_t hum_milli_pct;
    struct k_mutex lock;
};

static int xyz123_reg_read16(const struct device *dev, uint8_t reg, uint16_t *val)
{
    const struct xyz123_config *cfg = dev->config;
    uint8_t buf[2];
    int ret = i2c_burst_read_dt(&cfg->bus, reg, buf, sizeof(buf));

    if (ret < 0) {
        LOG_ERR("read reg 0x%02x failed: %d", reg, ret);
        return ret;
    }

    *val = ((uint16_t)buf[0] << 8) | buf[1];
    return 0;
}

static int xyz123_sample_fetch(const struct device *dev,
                               enum sensor_channel chan)
{
    const struct xyz123_config *cfg = dev->config;
    struct xyz123_data *data = dev->data;
    uint16_t raw_temp;
    uint16_t raw_hum;
    int ret;

    if (chan != SENSOR_CHAN_ALL &&
        chan != SENSOR_CHAN_AMBIENT_TEMP &&
        chan != SENSOR_CHAN_HUMIDITY) {
        return -ENOTSUP;
    }

    k_mutex_lock(&data->lock, K_FOREVER);

    ret = i2c_reg_write_byte_dt(&cfg->bus, XYZ123_REG_CONFIG, XYZ123_CMD_MEASURE);
    if (ret < 0) {
        goto out;
    }

    k_sleep(K_MSEC(20));

    ret = xyz123_reg_read16(dev, XYZ123_REG_TEMP_MSB, &raw_temp);
    if (ret < 0) {
        goto out;
    }

    ret = xyz123_reg_read16(dev, XYZ123_REG_HUM_MSB, &raw_hum);
    if (ret < 0) {
        goto out;
    }

    data->temp_milli_c = ((int32_t)raw_temp * 165000 / 65535) - 40000;
    data->hum_milli_pct = (int32_t)raw_hum * 100000 / 65535;

out:
    k_mutex_unlock(&data->lock);
    return ret;
}

这里用到了 i2c_dt_spec,它是 Zephyr 很推荐的写法。它把 I2C 控制器设备指针、从机地址等信息打包好,驱动里不需要自己解析 reg 或查找 bus device。

六、channel_get 与驱动 API 表

sensor_sample_fetch() 负责从硬件更新缓存,sensor_channel_get() 负责把缓存值转换成 Zephyr 的 sensor_value。继续补上后半部分:

static int xyz123_channel_get(const struct device *dev,
                              enum sensor_channel chan,
                              struct sensor_value *val)
{
    struct xyz123_data *data = dev->data;
    int32_t milli;

    k_mutex_lock(&data->lock, K_FOREVER);

    switch (chan) {
    case SENSOR_CHAN_AMBIENT_TEMP:
        milli = data->temp_milli_c;
        break;
    case SENSOR_CHAN_HUMIDITY:
        milli = data->hum_milli_pct;
        break;
    default:
        k_mutex_unlock(&data->lock);
        return -ENOTSUP;
    }

    val->val1 = milli / 1000;
    val->val2 = (milli % 1000) * 1000;

    k_mutex_unlock(&data->lock);
    return 0;
}

static int xyz123_init(const struct device *dev)
{
    const struct xyz123_config *cfg = dev->config;
    struct xyz123_data *data = dev->data;

    if (!device_is_ready(cfg->bus.bus)) {
        LOG_ERR("I2C bus is not ready");
        return -ENODEV;
    }

    k_mutex_init(&data->lock);
    LOG_INF("XYZ123 ready, period=%u ms", cfg->sample_period_ms);
    return 0;
}

static const struct sensor_driver_api xyz123_api = {
    .sample_fetch = xyz123_sample_fetch,
    .channel_get = xyz123_channel_get,
};

最后是实例化宏:

#define XYZ123_DEFINE(inst)                                                   \
    static struct xyz123_data xyz123_data_##inst;                             \
    static const struct xyz123_config xyz123_config_##inst = {                 \
        .bus = I2C_DT_SPEC_INST_GET(inst),                                     \
        .sample_period_ms = DT_INST_PROP_OR(inst, sample_period_ms, 1000),     \
    };                                                                         \
    SENSOR_DEVICE_DT_INST_DEFINE(inst,                                         \
        xyz123_init,                                                           \
        NULL,                                                                  \
        &xyz123_data_##inst,                                                   \
        &xyz123_config_##inst,                                                 \
        POST_KERNEL,                                                           \
        CONFIG_XYZ123_INIT_PRIORITY,                                           \
        &xyz123_api);

DT_INST_FOREACH_STATUS_OKAY(XYZ123_DEFINE)

DT_INST_FOREACH_STATUS_OKAY() 会遍历所有 compatible 为 demo,xyz123status = "okay" 的节点,并为每个节点展开一次 XYZ123_DEFINE(inst)。如果板子上有两个 xyz123,就会生成两个 Zephyr device。应用层无需改驱动,只要通过 alias、label 或节点标识拿到对应设备即可。

(第二部分完,约2500字)

七、应用层如何优雅地使用驱动

驱动写好后,应用层代码应该保持简洁。推荐在 overlay 中为传感器加一个 alias:

/ {
    aliases {
        env-sensor = &xyz123_sensor;
    };
};

&i2c1 {
    status = "okay";

    xyz123_sensor: xyz123@44 {
        compatible = "demo,xyz123";
        reg = <0x44>;
        sample-period-ms = <1000>;
    };
};

然后在 main.c 中使用:

#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/sensor.h>
#include <zephyr/sys/printk.h>

#define ENV_SENSOR_NODE DT_ALIAS(env_sensor)

#if !DT_NODE_HAS_STATUS(ENV_SENSOR_NODE, okay)
#error "env-sensor alias is not defined or not okay"
#endif

int main(void)
{
    const struct device *sensor = DEVICE_DT_GET(ENV_SENSOR_NODE);
    struct sensor_value temp;
    struct sensor_value hum;
    int ret;

    if (!device_is_ready(sensor)) {
        printk("sensor device is not ready\n");
        return 0;
    }

    while (1) {
        ret = sensor_sample_fetch(sensor);
        if (ret < 0) {
            printk("sample fetch failed: %d\n", ret);
            k_sleep(K_SECONDS(1));
            continue;
        }

        sensor_channel_get(sensor, SENSOR_CHAN_AMBIENT_TEMP, &temp);
        sensor_channel_get(sensor, SENSOR_CHAN_HUMIDITY, &hum);

        printk("T=%d.%06d C, RH=%d.%06d %%\n",
               temp.val1, temp.val2, hum.val1, hum.val2);

        k_sleep(K_SECONDS(1));
    }
}

这段代码有两个值得保留的习惯。第一,使用 DT_ALIAS() 让应用不依赖具体节点路径;第二,用 DT_NODE_HAS_STATUS() 在编译期检查 alias 是否存在。很多板级移植问题,越早在编译期发现越好。

八、构建、查看生成文件与定位 Devicetree 问题

假设目标板是 nucleo_f446re,可以这样构建:

west build -b nucleo_f446re -p always .

如果 Devicetree 或 binding 有问题,先不要急着改 C 代码。建议按下面顺序排查:

  1. 查看最终合并后的 Devicetree:build/zephyr/zephyr.dts
  2. 查看生成的宏:build/zephyr/include/generated/zephyr/devicetree_generated.h
  3. 确认节点是否存在、状态是否为 okay、compatible 是否正确。
  4. 确认 binding 是否被加载,属性名是否从短横线转换成下划线。
  5. 确认 Kconfig 中 CONFIG_XYZ123=y 是否真的生效,可查看 build/zephyr/.config

举个例子,如果你在驱动里写了:

#define DT_DRV_COMPAT demo_xyz123

但 overlay 中写成:

compatible = "demo,xyz-123";

那么 DT_INST_FOREACH_STATUS_OKAY(XYZ123_DEFINE) 不会展开任何实例,驱动编译了也不会生成设备。这个问题运行时很难看出来,但在 zephyr.dts 和 generated header 中一查就很明显。

九、把轮询模式升级为中断触发模式

工业现场常见的传感器并不适合盲目轮询:有些数据更新慢,轮询浪费功耗;有些数据到达时间不固定,轮询会增加延迟。Zephyr 的 sensor API 支持 trigger 模式,可以让驱动在数据就绪中断到来时回调应用。

实现 trigger 时,建议分三层处理:

  • GPIO 中断回调里只做最小工作,例如提交 k_work
  • k_work 中读取状态寄存器,确认确实是 data-ready。
  • 再调用用户注册的 sensor_trigger_handler_t

伪代码如下:

#ifdef CONFIG_XYZ123_TRIGGER
struct xyz123_data {
    int32_t temp_milli_c;
    int32_t hum_milli_pct;
    struct k_mutex lock;
    struct gpio_callback gpio_cb;
    struct k_work work;
    sensor_trigger_handler_t handler;
    const struct sensor_trigger *trigger;
};

static void xyz123_gpio_callback(const struct device *port,
                                 struct gpio_callback *cb,
                                 uint32_t pins)
{
    struct xyz123_data *data = CONTAINER_OF(cb, struct xyz123_data, gpio_cb);
    k_work_submit(&data->work);
}
#endif

实际实现时,还要把 int-gpios 加入 config,并在 init 中配置 GPIO 输入和中断边沿。这里最容易犯的错是直接在 GPIO ISR 里读 I2C。大多数 I2C API 不是 ISR-safe,ISR 里做总线访问也会拖长中断关闭时间。用 workqueue 把慢操作挪到线程上下文,是更稳妥的写法。

十、可复用驱动的几个工程化细节

驱动能跑只是第一步,要在多个项目里复用,还需要注意一些“看似小、但会影响维护”的细节。

1. 不要在驱动里写板级策略

驱动应该负责“如何和芯片通信”,不应该决定“产品多久采一次样、异常时重启几次、数据上传到哪里”。这些策略放在应用层或服务层。驱动最多提供配置属性和 API。

2. 错误码要保持 Zephyr 风格

I2C 失败返回底层错误码,参数不支持返回 -ENOTSUP,设备不存在返回 -ENODEV,参数非法返回 -EINVAL。统一错误码后,应用层才能做通用处理。

3. 日志不要过度

驱动 init 失败、总线读写失败可以打 error;每次采样成功不建议打 info,否则量产设备日志会被刷爆。必要时用 debug 等级,并让 Kconfig 控制日志级别。

4. 兼容字符串一旦发布就别随意改

compatible 相当于驱动和硬件描述之间的 ABI。公司内部项目也一样,改名会导致旧 overlay 失效。需要支持新版本芯片时,可以新增 compatible,例如 demo,xyz123b,而不是直接替换旧名称。

十一、常见问题清单

1. DEVICE_DT_GET() 能编译但运行时报 device not ready

优先检查父总线是否 ready,例如 I2C 控制器的 status 是否为 okay,时钟和 pinctrl 是否配置正确。传感器设备 ready 依赖总线 ready。

2. DT_INST_PROP() 报宏不存在

通常是 binding 没加载、compatible 不匹配、属性名写错,或属性没有定义在 YAML 中。先看 zephyr.dts,再看 generated header。

3. Kconfig 里已经 default y,为什么 CONFIG_XYZ123 还是没打开

检查 depends on DT_HAS_DEMO_XYZ123_ENABLED 是否为真。如果 Devicetree 没有 enabled 节点,default y 也不会生效。

4. 多实例驱动只有一个设备出现

确认每个节点都有唯一 unit address,例如 xyz123@44xyz123@45,并且 reg 地址不同。I2C 子节点的 unit address 应该与 reg 对应。

5. overlay 文件没有生效

板级 overlay 默认路径通常是 boards/<board>.overlay。也可以构建时显式指定:

west build -b nucleo_f446re -- -DDTC_OVERLAY_FILE=boards/nucleo_f446re.overlay

十二、调试建议:先让链路透明,再追性能

很多 Zephyr 驱动问题不是算法问题,而是“我不知道构建系统最后生成了什么”。所以调试时要养成看中间产物的习惯。确认 Devicetree 合并结果,确认 .config,确认驱动源文件是否被编译,确认日志模块是否打开。等这些基础链路透明后,再去优化采样频率、功耗和总线吞吐。

对于 I2C 传感器,我通常会按下面顺序做 bring-up:

  1. 用示波器或逻辑分析仪确认 SCL/SDA 有波形。
  2. 用 Zephyr 的 I2C scan sample 确认从机地址存在。
  3. 在驱动 init 中读取芯片 ID 寄存器。
  4. 再实现 sample_fetch 和 channel_get。
  5. 最后才加 trigger、低功耗和校准逻辑。

这个顺序看起来慢,实际上最省时间。因为它把“硬件焊接问题”“Devicetree 配置问题”“驱动协议问题”“应用使用问题”分开了。

十三、从样机驱动走向量产代码

样机阶段的 Zephyr 驱动往往只关注“能不能读到数据”,量产阶段则要多考虑几个维度:异常恢复、功耗状态、版本兼容和可测试性。以 I2C 传感器为例,现场环境中可能出现总线被拉低、传感器短暂掉电、热插拔连接器接触不良、外部干扰导致 CRC 错误等情况。如果驱动只在初始化阶段检查一次芯片 ID,后续读写失败就直接返回错误,应用层会很难判断是偶发总线错误还是设备永久失效。

比较稳妥的做法是给驱动内部增加有限状态,而不是把所有复杂性都暴露给应用。比如连续 3 次读写失败后,把设备标记为 SUSPICIOUS,下一次采样前先尝试软复位;软复位失败再返回 -EIO-ENODEV。应用层仍然只调用 sensor_sample_fetch(),但可以根据错误码决定是否上报健康状态、降低采样频率或触发系统级恢复。注意这个状态机不要写得过重,驱动不是业务流程引擎,它只需要提供足够可靠的硬件访问边界。

功耗也是量产代码必须补上的部分。Zephyr 的设备电源管理可以让驱动实现 suspend / resume,在系统进入低功耗前关闭传感器测量,在唤醒后重新配置寄存器。对于电池供电设备,传感器本身的待机电流、I2C 上拉电阻、GPIO 中断唤醒方式都会影响整机续航。很多项目一开始只测 MCU 的 sleep 电流,忽略外设持续工作,最后发现整板电流比预期高一个数量级。驱动如果把 power mode 做成 Kconfig 或 Devicetree 属性,后续不同产品线复用时会方便很多。

还有一个容易被忽略的点是芯片批次和寄存器版本。真实供应链中,同一个型号可能有 A 版、B 版,甚至兼容厂商的替代料。建议驱动 init 阶段读取 chip id 和 revision,把关键信息打到 debug 日志里,并为已知差异留出表驱动配置。例如某个 revision 的湿度补偿公式不同,最好在驱动内部用 quirks 标志处理,而不是让应用层到处写 if。

十四、如何为 Zephyr 驱动写测试

驱动测试不能只依赖真板子。真板子当然必须测,但它不适合覆盖所有异常分支,也不适合在 CI 中频繁运行。Zephyr 自带的 ztest 和 native 模拟目标可以帮助我们把一部分逻辑拆出来做单元测试。对于 xyz123 这类传感器驱动,寄存器换算、错误码处理、状态机迁移、配置参数边界,都可以抽成纯函数或轻量 mock。

一种实用策略是把“协议解析”和“总线访问”分层。总线访问函数只负责 i2c_burst_read_dt()i2c_reg_write_byte_dt() 这些硬件动作;协议层函数负责把 raw data 转换成毫摄氏度、毫百分比,负责检查状态位和 CRC。这样即使没有 I2C mock,也能对协议层做大量测试。比如温度原始值为 0 时应接近 -40.000 C,原始值为 65535 时应接近 125.000 C;湿度原始值超出合理范围时是否钳位,也可以明确写成测试用例。

CI 中可以至少跑三类检查。第一类是格式和静态检查,确保驱动代码符合项目风格;第二类是 Kconfig 与 Devicetree 构建矩阵,例如一个 overlay 开启中断、一个 overlay 关闭中断、一个 overlay 使用第二个 I2C 实例;第三类是 native 单元测试,覆盖换算、状态机和错误分支。这样做的收益在硬件改版时非常明显:当 overlay、binding 或 Kconfig 被别人改动,CI 能尽早告诉你哪个组合坏了。

真板测试则更关注时序和电气边界。建议保留一份 bring-up checklist:逻辑分析仪抓取 I2C 地址和 ACK,冷启动连续采样 24 小时,热插拔或模拟掉电恢复,低温高温环境下读取 chip id 和数据稳定性,中断触发下是否丢事件。驱动文档里把这些测试结果记录下来,比只留一段“已验证”更有价值。

十五、移植到公司内部平台时的落地建议

很多团队引入 Zephyr 不是从零开始,而是已有一套历史 BSP、HAL 和应用框架。迁移时不要试图一口气把所有驱动都改成 Zephyr 风格,最好选择一个边界清晰的外设作为试点,比如温湿度传感器、GPIO 扩展器或简单 ADC。试点目标不是证明 Zephyr 能跑,而是沉淀一套团队可复制的模板:目录结构、命名规范、binding 写法、日志级别、错误码约定、测试方式和 code review 清单。

命名规范建议尽早统一。compatible 可以采用公司域名前缀或产品前缀,例如 acme,xyz123;Kconfig 选项保持大写芯片名;驱动文件名和日志模块名保持小写。属性名尽量使用 Zephyr 社区常见表达,例如 int-gpiosreset-gpiossupply-gpiossample-period-ms,不要每个团队自造一套叫法。统一命名会直接降低后续维护和搜索成本。

代码评审时,可以专门检查几个问题:C 代码里是否写死了 I2C 地址或 GPIO 编号;驱动是否支持多实例;binding 是否声明了所有自定义属性;Kconfig 是否错误地强制选择了不必要组件;应用层是否绕过 sensor API 直接访问驱动私有结构;错误路径是否释放锁;ISR 中是否调用了可能睡眠的 API。这些检查点比单纯看代码是否“能编译”更接近工程质量。

最后,文档要和模板一起交付。Zephyr 的学习曲线主要来自工具链和生成文件,如果团队里只有一两个人知道怎么查 zephyr.dts.config,项目风险会很高。建议在仓库中放一份 docs/driver-porting.md,写清楚新增一个传感器需要改哪些文件、构建命令是什么、常见报错怎么查。长期看,这份文档会比一次性的口头培训更可靠。

总结

Zephyr 的 Devicetree、Kconfig 和驱动模型一开始确实比裸机宏定义复杂,但它解决的是产品化阶段绕不开的问题:硬件差异如何管理,功能如何裁剪,驱动如何多实例复用,应用如何摆脱板级细节。掌握它的关键不是背宏,而是理解职责边界:Devicetree 描述硬件,Kconfig 描述软件选项,驱动用实例化宏把两者连接起来,应用层只面对标准 API。

本文用 xyz123 I2C 传感器走了一遍完整链路:overlay、binding、Kconfig、CMake、驱动实现、应用调用、构建调试和工程化建议。把这套流程跑通后,再写 SPI Flash、GPIO 扩展器、ADC 采样芯片或自定义工业传感器,本质上都是同一套方法。真正值得长期保留的经验是:不要把板级信息写死在 C 代码里,不要跳过生成文件排查,不要在驱动里塞产品策略。这样写出来的 Zephyr 驱动,才有机会从一次性样机代码变成可复用的工程资产。

(全文完,约8800字)