Linux字符设备驱动开发(五):PWM调光——实现LED亮度控制与呼吸灯效果

前言

上一篇文章中,我们通过GPIO子系统点亮和熄灭开发板上的LED,但这只是最简单的开关控制。如果想让LED像手机呼吸灯一样平滑地明暗变化,就需要用到一个更强大的外设------PWM(脉宽调制)

本文将利用PWM子系统实现LED的亮度调节,从零编写一个可设置占空比的字符设备驱动,并通过一个Shell脚本在应用层实现流畅的呼吸灯效果。你将掌握:

  • PWM的基本原理与Linux PWM子系统
  • 设备树中PWM节点的配置
  • pwm_apply_state等新版API的使用
  • 如何在驱动中根据用户输入动态调整占空比
  • 应用层脚本实现呼吸灯

一、PWM子系统简介

1.1 什么是PWM?

PWM(Pulse Width Modulation,脉宽调制)是一种对模拟信号电平进行数字编码的方法。它由**周期(Period)占空比(Duty Cycle)**两个核心参数决定:

复制代码
周期 T = 高电平时间 + 低电平时间
占空比 = 高电平时间 / 周期 × 100%

通过调整占空比,可以等效地改变输出电压的平均值,从而控制LED的亮度、电机的转速等。

1.2 Linux PWM子系统

Linux内核提供了统一的PWM框架,包含两个层次:

  • PWM控制器驱动(芯片原厂实现):操作硬件寄存器,向上层提供标准接口。
  • PWM消费者接口 (我们写驱动时使用):通过<linux/pwm.h>中的API申请和使用PWM通道。

常用API(新版)

函数 作用
pwm_get(dev, con_id) 从设备树获取PWM设备描述符
pwm_put(pwm) 释放PWM设备
pwm_init_state(pwm, &state) 用设备树参数初始化一个PWM状态
pwm_apply_state(pwm, &state) 应用新的PWM状态(周期、占空比、极性等)

旧版API (如pwm_config()pwm_enable())在较新内核中已标记为废弃,推荐统一使用pwm_apply_state()


二、设计思路

本文以韦东山课程常用的i.MX6ULL 开发板为例,使用PWM3外设控制一个LED(假设LED连接在PWM3的输出引脚上)。驱动设计如下:

  • 在设备树中添加一个pwm-led节点,引用pwm3控制器。
  • 驱动采用platform_driver架构,与设备树节点匹配。
  • probe中获取PWM设备,设置默认周期为50000ns(20kHz),初始占空比为0(LED熄灭)。
  • 通过/dev/pwmled设备节点向用户空间提供接口:写入0~100的百分比数字设置占空比,读取时返回当前百分比。
  • 应用层通过Shell脚本循环调整占空比,产生呼吸灯效果。

三、设备树修改

3.1 确认PWM引脚

查看原理图,找到接有LED的PWM引脚。以i.MX6ULL开发板为例,PWM3 通常用于LCD背光,但如果LED硬件连接到了PWM3引脚,就可以使用。假设LED连接在PWM3输出引脚上,对应GPIO为GPIO1_IO04GPIO4_IO01等(取决于具体板子)。设备树中需确保该引脚未被其他功能占用。

3.2 添加PWM LED设备树节点

在板级设备树文件中添加如下节点(确保文件头部包含<dt-bindings/pwm/pwm.h>以使用PWM_POLARITY_INVERTED等宏):

dts 复制代码
/ {
    pwm_led {
        compatible = "yourname,pwm-led";
        pwms = <&pwm3 0 50000>;   /* 使用pwm3,通道0,周期50000ns */
        status = "okay";
    };
};

属性说明

  • compatible:与驱动中的of_match_table匹配。
  • pwms:指定PWM控制器、通道号和默认周期(单位纳秒)。50000ns = 20kHz,这是人眼不闪烁的合适频率。
  • 注意:某些旧设备树可能还需要显式配置引脚复用(pinctrl),这里假设内核已默认配置好。如果LED不亮,请检查引脚复用设置。

重新编译设备树并替换后重启开发板。


四、驱动代码实现

新建pwmled_drv.c,完整代码如下。所有错误处理路径均严格编写,并使用了新版pwm_apply_state()

c 复制代码
/*
 * pwmled_drv.c
 * PWM LED 字符设备驱动。
 * 使用 platform_driver 匹配设备树节点,通过 pwm_apply_state() 控制占空比。
 * 设备节点 /dev/pwmled:写入 0-100 百分比控制亮度,读取返回当前百分比。
 * 作者:[你的ID]
 * 适配内核:Linux 5.x (4.x 亦可)
 * 参考开发板:i.MX6ULL(韦东山课程配套板)
 */

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#include <linux/platform_device.h>
#include <linux/pwm.h>              /* PWM API */
#include <linux/of.h>

#define DEVICE_NAME "pwmled"
#define CLASS_NAME  "pwmled_class"

/* 默认周期 50000ns (20kHz),占空比范围 0 ~ period */
#define PWM_PERIOD_NS  50000

static dev_t dev_num;
static struct cdev my_cdev;
static struct class *my_class;
static struct device *my_device;

static struct pwm_device *led_pwm;   /* PWM设备描述符 */
static u32 current_duty_ns;          /* 当前占空比(纳秒) */

/* 打开设备 */
static int pwmled_open(struct inode *inode, struct file *file)
{
    pr_info("pwmled: device opened\n");
    return 0;
}

/* 关闭设备 */
static int pwmled_release(struct inode *inode, struct file *file)
{
    pr_info("pwmled: device closed\n");
    return 0;
}

/* 读取当前占空比百分比(0 - 100) */
static ssize_t pwmled_read(struct file *file, char __user *buf,
                           size_t count, loff_t *f_pos)
{
    char kbuf[8];
    int percent;
    int len;

    percent = (current_duty_ns * 100) / PWM_PERIOD_NS;
    len = snprintf(kbuf, sizeof(kbuf), "%d\n", percent);

    if (*f_pos >= len)
        return 0;      /* EOF */

    if (copy_to_user(buf, kbuf, len))
        return -EFAULT;

    *f_pos += len;
    return len;
}

/* 写入占空比百分比:接收 "0" ~ "100" 的数字字符串 */
static ssize_t pwmled_write(struct file *file, const char __user *buf,
                            size_t count, loff_t *f_pos)
{
    char kbuf[8] = {0};
    unsigned long percent;
    int ret;
    struct pwm_state state;

    if (count > 7)
        count = 7;      /* 最多允许输入 "100" + 换行 = 4字节,7已足够 */

    if (copy_from_user(kbuf, buf, count))
        return -EFAULT;

    ret = kstrtoul(kbuf, 0, &percent);
    if (ret < 0) {
        pr_warn("pwmled: invalid input, not a number\n");
        return -EINVAL;
    }

    if (percent > 100) {
        pr_warn("pwmled: percentage out of range (0-100)\n");
        return -EINVAL;
    }

    /* 计算占空比纳秒值 */
    current_duty_ns = (PWM_PERIOD_NS * percent) / 100;

    /* 获取当前PWM状态,修改占空比并应用 */
    pwm_get_state(led_pwm, &state);
    state.duty_cycle = current_duty_ns;
    state.enabled = (current_duty_ns > 0);  /* 占空比为0时关闭PWM输出 */
    ret = pwm_apply_state(led_pwm, &state);
    if (ret) {
        pr_err("pwmled: pwm_apply_state failed\n");
        return -EFAULT;
    }

    pr_info("pwmled: set duty = %lu%% (%u ns)\n", percent, current_duty_ns);
    return count;
}

static struct file_operations pwmled_fops = {
    .owner   = THIS_MODULE,
    .open    = pwmled_open,
    .release = pwmled_release,
    .read    = pwmled_read,
    .write   = pwmled_write,
};

/* ---------------- platform_driver 部分 ---------------- */

static int pwmled_probe(struct platform_device *pdev)
{
    int ret;
    struct device *dev = &pdev->dev;
    struct pwm_state state;

    pr_info("pwmled: probe called\n");

    /* 1. 获取PWM设备 */
    led_pwm = pwm_get(dev, NULL);
    if (IS_ERR(led_pwm)) {
        pr_err("pwmled: failed to get pwm device\n");
        return PTR_ERR(led_pwm);
    }

    /* 2. 初始化PWM状态:周期固定,占空比0(LED灭) */
    pwm_init_state(led_pwm, &state);
    state.period = PWM_PERIOD_NS;
    state.duty_cycle = 0;
    state.enabled = true;    /* 使能PWM输出,占空比0时LED熄灭 */
    ret = pwm_apply_state(led_pwm, &state);
    if (ret) {
        pr_err("pwmled: pwm_apply_state failed\n");
        pwm_put(led_pwm);
        return ret;
    }
    current_duty_ns = 0;

    /* 3. 动态分配设备号 */
    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        pr_err("pwmled: alloc_chrdev_region failed\n");
        goto err_alloc;
    }

    /* 4. 初始化cdev */
    cdev_init(&my_cdev, &pwmled_fops);
    my_cdev.owner = THIS_MODULE;
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret) {
        pr_err("pwmled: cdev_add failed\n");
        goto err_cdev_add;
    }

    /* 5. 创建class和设备节点 */
    my_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(my_class)) {
        pr_err("pwmled: class_create failed\n");
        ret = PTR_ERR(my_class);
        goto err_class_create;
    }

    my_device = device_create(my_class, dev, dev_num, NULL, DEVICE_NAME);
    if (IS_ERR(my_device)) {
        pr_err("pwmled: device_create failed\n");
        ret = PTR_ERR(my_device);
        goto err_device_create;
    }

    pr_info("pwmled: /dev/%s created, period=%u ns\n", DEVICE_NAME, PWM_PERIOD_NS);
    return 0;

err_device_create:
    class_destroy(my_class);
err_class_create:
    cdev_del(&my_cdev);
err_cdev_add:
    unregister_chrdev_region(dev_num, 1);
err_alloc:
    /* 安全关闭PWM:获取当前状态,禁用后再释放 */
    pwm_get_state(led_pwm, &state);
    state.enabled = false;
    pwm_apply_state(led_pwm, &state);
    pwm_put(led_pwm);
    return ret;
}

static int pwmled_remove(struct platform_device *pdev)
{
    struct pwm_state state;

    /* 关闭PWM输出并释放资源 */
    pwm_get_state(led_pwm, &state);
    state.enabled = false;
    pwm_apply_state(led_pwm, &state);

    device_destroy(my_class, dev_num);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    pwm_put(led_pwm);

    pr_info("pwmled: module unloaded\n");
    return 0;
}

/* 设备树匹配表 */
static const struct of_device_id pwmled_of_match[] = {
    { .compatible = "yourname,pwm-led" },
    { }
};
MODULE_DEVICE_TABLE(of, pwmled_of_match);

static struct platform_driver pwmled_driver = {
    .probe  = pwmled_probe,
    .remove = pwmled_remove,
    .driver = {
        .name           = "pwmled",
        .owner          = THIS_MODULE,
        .of_match_table = pwmled_of_match,
    },
};

module_platform_driver(pwmled_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A PWM LED character device driver");
MODULE_VERSION("1.0");

代码关键点

  • pwm_get(dev, NULL):获取设备树pwms属性指定的PWM资源。
  • pwm_init_state() + 手动修改state.periodstate.duty_cycle后调用pwm_apply_state(),这是标准的初始化流程。
  • 写函数中将百分比转换为纳秒占空比,并仅在占空比大于0时使能输出(节能)。
  • 错误路径使用goto进行完整清理,并确保PWM输出关闭;关闭时先通过pwm_get_state获取当前状态再禁用,避免未初始化字段。

五、Makefile

makefile 复制代码
# Makefile for pwmled

KERNEL_DIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

obj-m := pwmled_drv.o

all:
	make -C $(KERNEL_DIR) M=$(PWD) modules

clean:
	make -C $(KERNEL_DIR) M=$(PWD) clean

交叉编译时设置ARCHCROSS_COMPILE环境变量,同前几篇文章。


六、测试与验证

6.1 加载驱动

pwmled_drv.ko拷贝到开发板,执行:

bash 复制代码
insmod pwmled_drv.ko

查看日志:

bash 复制代码
dmesg | tail
# pwmled: probe called
# pwmled: /dev/pwmled created, period=50000 ns

6.2 确认设备节点

bash 复制代码
ls -l /dev/pwmled
# crw------- 1 root root 238, 0 ...
chmod 666 /dev/pwmled

6.3 手动控制亮度

bash 复制代码
echo 0 > /dev/pwmled    # 熄灭
echo 25 > /dev/pwmled   # 25% 亮度
echo 50 > /dev/pwmled   # 半亮
echo 100 > /dev/pwmled  # 最亮

观察LED亮度变化。读取当前百分比:

bash 复制代码
cat /dev/pwmled
# 输出当前设置值,例如 50

6.4 呼吸灯脚本

编写breath.sh,通过循环平滑改变占空比来实现呼吸效果。

bash 复制代码
#!/bin/bash

DEV="/dev/pwmled"
STEP=2          # 每次变化的百分比步长
INTERVAL=0.02   # 每次变化的间隔(秒)

echo "Starting breath effect on $DEV..."
while true; do
    # 从0渐亮到100
    for ((i=0; i<=100; i+=STEP)); do
        echo $i > $DEV
        sleep $INTERVAL
    done
    # 从100渐暗到0
    for ((i=100; i>=0; i-=STEP)); do
        echo $i > $DEV
        sleep $INTERVAL
    done
done

赋予执行权限并运行:

bash 复制代码
chmod +x breath.sh
./breath.sh

你将看到LED像呼吸一样平滑地亮灭。按Ctrl+C停止脚本后,可以手动设置占空比。

如果开发板的sleep命令不支持小数(例如某些busybox版本),可将sleep $INTERVAL替换为usleep 20000(20000微秒 = 0.02秒)。

6.5 卸载驱动

bash 复制代码
rmmod pwmled_drv

LED自动熄灭,/dev/pwmled消失。


七、常见问题排查

  1. insmod报错Unknown symbol pwm_apply_state

    内核可能未开启PWM子系统或API版本过旧。检查内核配置CONFIG_PWM=y,或尝试使用旧版API(pwm_config + pwm_enable)。

  2. LED不亮,但驱动加载成功

    • 确认设备树中的PWM通道与实际硬件连接一致。
    • 检查引脚复用(pinctrl)是否已配置为PWM功能。
    • 用示波器测量PWM引脚是否有波形输出。
  3. 占空比设置与亮度不对应

    检查LED的硬件极性:若LED为共阳连接,则有效电平为低,需要在设备树pwms属性中设置第三参数为PWM_POLARITY_INVERTED(如<&pwm3 0 50000 PWM_POLARITY_INVERTED>),并在驱动中设置state.polarity = PWM_POLARITY_INVERTED

  4. 呼吸灯闪烁不连贯

    Shell脚本中的sleep精度有限,若间隔过长会导致可见的阶梯变化。可改用C语言程序调用usleep(),或在内核空间使用定时器直接实现平滑渐变(但增加驱动复杂度)。


八、总结与下篇预告

本文通过PWM子系统实现了LED亮度的动态调节,并配合简单脚本完成了经典的呼吸灯效果。从GPIO的数字开关到PWM的模拟控制,我们的硬件操控能力又上了一个台阶。

下篇预告 :在实际项目中,驱动往往需要同时处理多种外设,并传递更丰富的数据。下一篇我们将探索I2C子系统,驱动一个常见的I2C传感器(如MPU6050或AT24C02 EEPROM),学习如何在内核中与I2C设备通信并暴露数据给用户空间。敬请期待!


如果本文对你有帮助,欢迎点赞、收藏、关注。有任何技术疑问,欢迎在评论区留言交流!

相关推荐
YDS8291 小时前
浅谈近期关于Docker部署产生的一些问题
运维·docker·容器
爱喝水的鱼丶1 小时前
SAP-ABAP:变量、常量、结构与内表声明(10篇博客合集) 第六篇:ABAP 7.40+新特性:声明语法的简化写法与兼容注意事项
运维·服务器·开发语言·学习·算法·sap·abap
Hani_971 小时前
Code Coverage系列(三)gcov 是什么?做什么?两个参数?检测原理?gcno文件内容?gcda文件内容?
linux·代码覆盖率
青梅橘子皮1 小时前
Linux---进程状态与优先级
linux·运维·服务器
H Journey2 小时前
Linux VIM介绍与常用命令
linux·运维·vim
invicinble2 小时前
设计模式(类的拓扑结构)(为什么会产生设计模式,以及什么是设计模式)
linux·服务器·设计模式
bukeyiwanshui2 小时前
20260526 综合实践:企业网站上云部署实践
运维·服务器
齐潇宇2 小时前
DevOps介绍与工具链全解析
运维·devops·cicd
Arik~朽木2 小时前
Ubuntu 安装指南
linux·运维·ubuntu