前言
在上一篇文章中,我们通过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_IO04或GPIO4_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.period和state.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
交叉编译时设置ARCH和CROSS_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消失。
七、常见问题排查
-
insmod报错Unknown symbol pwm_apply_state内核可能未开启PWM子系统或API版本过旧。检查内核配置
CONFIG_PWM=y,或尝试使用旧版API(pwm_config+pwm_enable)。 -
LED不亮,但驱动加载成功
- 确认设备树中的PWM通道与实际硬件连接一致。
- 检查引脚复用(pinctrl)是否已配置为PWM功能。
- 用示波器测量PWM引脚是否有波形输出。
-
占空比设置与亮度不对应
检查LED的硬件极性:若LED为共阳连接,则有效电平为低,需要在设备树
pwms属性中设置第三参数为PWM_POLARITY_INVERTED(如<&pwm3 0 50000 PWM_POLARITY_INVERTED>),并在驱动中设置state.polarity = PWM_POLARITY_INVERTED。 -
呼吸灯闪烁不连贯
Shell脚本中的
sleep精度有限,若间隔过长会导致可见的阶梯变化。可改用C语言程序调用usleep(),或在内核空间使用定时器直接实现平滑渐变(但增加驱动复杂度)。
八、总结与下篇预告
本文通过PWM子系统实现了LED亮度的动态调节,并配合简单脚本完成了经典的呼吸灯效果。从GPIO的数字开关到PWM的模拟控制,我们的硬件操控能力又上了一个台阶。
下篇预告 :在实际项目中,驱动往往需要同时处理多种外设,并传递更丰富的数据。下一篇我们将探索I2C子系统,驱动一个常见的I2C传感器(如MPU6050或AT24C02 EEPROM),学习如何在内核中与I2C设备通信并暴露数据给用户空间。敬请期待!
如果本文对你有帮助,欢迎点赞、收藏、关注。有任何技术疑问,欢迎在评论区留言交流!