Linux字符设备驱动案例:用SPI+DMA点亮WS2812,从时序拼凑到驱动八步实现

    科创经济 朗峰江湖 2026-09-18 4770 次浏览

    WS2812是一款单总线(单线串行)智能LED控制芯片,每颗灯珠内置控制电路,接收24 bit的RGB数据后,多余的位自动透传给下一颗,从而实现整条灯带的级联控制。它不依赖时钟线,仅用一根数据线,通过一段时间内高低电平的占空比来区分数据0和1——这一特性决定了它对时序精度要求极高,也是整个驱动设计的出发点。

    5eb8c376-b272-11f1-ab55-92fbcf53809c.png

    Linux系统中驱动WS2812,最直观的做法是在应用层用GPIO翻转电平,一位一位地把数据发出去。但这种方式存在两个硬伤:一是持续占用CPU,发送一帧数据期间CPU需频繁操作电平,无法执行其他任务;二是时序抖动,Linux并非实时系统,应用层翻转电平会被进程调度、中断响应和缓存命中等因素干扰,一旦电平宽度超出WS2812的容差范围,灯带即显示错乱。

    本文采用「SPI模拟时序 + DMA搬运」的方案:由SPI控制器产生精确波形,DMA负责数据搬运,CPU仅承担RGB编码,最终通过Linux字符设备向用户层暴露/dev/ws2812_spiX节点,应用程序只需write()一帧RGB数据即可点灯。以下基于瑞芯微平台,记录从时序设计、实测定量验证到字符设备驱动八步实现的完整过程。

    01

    WS2812时序要求码元与复位信号

    WS2812通过一个码元周期(约1.25 μs)内高低电平的占空比来区分0和1:

    除上表外,还有两个关键参数:

    码元周期 ≈ 1.25 μs: 高电平与低电平合计约1.25 μs。

    复位(锁存)信号: 一帧数据全部发送完成后,必须保持低电平 ≥ 280 μs,WS2812以此作为"本帧结束"的信号来锁存颜色。本驱动在发送完成后调用 udelay(300) 留出余量。

    02

    SPI码元映射3.2 MHz时钟下的4-bit拼接

    核心设计在于:不自己翻转电平,而是让SPI硬件"拼"出WS2812的码元波形

    SPI的MOSI线在时钟驱动下逐位输出数据,每一位的宽度 = 1 / SPI时钟频率,波形由硬件保证。将SPI时钟配置为 3.2 MHz,则1个SPI bit ≈ 312 ns,4个SPI bit拼接为1个WS2812码元 ≈ 1.25 μs,正好等于WS2812的码元周期。

    码元映射表:

    两者的高低电平均落在WS2812的时序容差范围内,灯珠可正确识别。

    编码实现(1字节RGB → 4字节SPI码元流):

    /* WS2812 bit → SPI 4-bit 码元 */staticconstu8 ws2812_bit_to_spi[2] = {0x8,0xE};/* 1 字节 RGB(8 个 WS2812 bit)→ 4 字节 SPI 码元流 */staticvoidws2812_encode_byte(u8in, u8 *out){ inti; for(i =0; i < 4; i++) {        u8 high = ws2812_bit_to_spi[(in >> (7- i *2)) &0x1]; u8 low = ws2812_bit_to_spi[(in>> (6- i *2)) &0x1]; out[i] = (high << 4) | low;   /* 高 4 位先发 */    }}

    0xA51010_0101b)为例:最高位1 →1110,次高位0 →1000,拼成第一个输出字节1110_1000 = 0xE8……8个WS2812 bit恰好编码为4个输出字节,实现1:4 的数据膨胀

    5efd714c-b272-11f1-ab55-92fbcf53809c.png

    03

    数据膨胀与DMA触发为什么灯珠多反而更省CPU

    每颗WS2812灯珠需要RGB888 = 24 bit数据,编码后按1:4膨胀:

    1颗灯珠 = 24 × 4 = 96 SPI bit = 12字节

    对照瑞芯微SPI驱动默认FIFO长度64字节这一DMA触发阈值:

    5f13a962-b272-11f1-ab55-92fbcf53809c.png

    FIFO可理解为SPI控制器的"暂存缸":CPU把数据舀进缸里,硬件自行舀出发送;当一次发送的数据量 ≥ FIFO长度时,瑞芯微SPI控制器驱动会自动触发DMA搬运,直接由DMA从内存往FIFO里"倒",CPU可以转去执行其他任务。

    这恰好契合WS2812的场景:短灯带数据量少,CPU推FIFO即可;长灯带自动切换DMA。 驱动代码本身无需关心传输细节,只需调用spi_sync(),底层自动选择最优路径。

    04

    实测定量验证逻辑分析仪抓取真实波形

    使用24 MHz逻辑分析仪抓取实际波形,验证编码方案的可靠性。

    实测结果(抓取波形为0码元,对应SPI码元1000b):

    5f1b8e3e-b272-11f1-ab55-92fbcf53809c.png

    说明: 逻辑分析仪采样率为24 MHz,精度约±40 ns,实测数据仅供参考。0码元实测值与理论值基本吻合,均落在时序容差内;1码元(1110b,理论938 ns / 312 ns)同样完全落在T1H(580~1000 ns)/ T1L(220~420 ns)容差内。实际点灯显示稳定,也从侧面印证了编码的可靠性,证明SPI码元映射方案可行

    5f238a26-b272-11f1-ab55-92fbcf53809c.png

    05

    字符设备驱动八步实现

    驱动以ws2812_spi.c为例,按"每步可编译"的方式逐步搭建——每一步结束后,当前代码都能独立通过编译并insmod验证,可随时停下来make && insmod确认无误再继续。

    该驱动同时扮演两个角色:SPI客户机驱动(挂在SPI总线上,设备树匹配成功后由内核回调probe())与字符设备驱动(向用户层提供/dev/ws2812_spiX,实现open/read/write/ioctl)。

    八步总览:

    ① 最小模块骨架 → ② probe硬件初始化 →

    ③ 字符设备注册四件套 → ④ open取回设备 →

    ⑤ write数据通路(核心) → ⑥ read回读 →

    ⑦ ioctl动态调参 → ⑧ remove逆序清理

    5f36b362-b272-11f1-ab55-92fbcf53809c.png

    5.1 write():核心数据通路

    write()实现整条数据流水线:编码 → SPI发送 → 落锁保护。

    staticssize_tws2812_write(structfile *file,constchar__user *buf, size_tcount,loff_t*ppos){ structws2812_dev*dev = file->private_data; size_texpected = (size_t)dev->num_leds *3; /* 每颗灯 3 字节 RGB */ u8 *rgb; ssize_tret; if(count != expected) /* 必须一次写满整帧,否则拒绝 */ return-EINVAL; rgb =kmalloc(expected, GFP_KERNEL); if(!rgb) return-ENOMEM; if(copy_from_user(rgb, buf, expected)) { /* 内核态 ← 用户态 */ kfree(rgb); return-EFAULT; } mutex_lock(&dev->lock); /* 防止 ioctl 并发修改 num_leds 导致缓冲区越界 */ if(count != (size_t)dev->num_leds *3) { /* 二次检查 */ mutex_unlock(&dev->lock); kfree(rgb); return-EINVAL; } memcpy(dev->rgb_buf, rgb, expected); /* 存一份供 read 回读 */ ws2812_encode_frame(dev, rgb); /* RGB → SPI 码元 */ ret =ws2812_send(dev); /* SPI 发送 */ mutex_unlock(&dev->lock); kfree(rgb); returnret < 0 ? ret : count;}

    SPI发送函数(含复位锁存):

    staticintws2812_send(structws2812_dev *dev){ structspi_transfer t = { .tx_buf = dev->tx_buf, .len = dev->tx_len, .bits_per_word =8, .speed_hz = dev->spi->max_speed_hz, }; structspi_message msg; spi_message_init(&msg); spi_message_add_tail(&t, &msg); spi_sync(dev->spi, &msg); /* len ≥ 64 时底层自动走 DMA */ /* WS2812 要求最后一个数据位后保持 ≥280 μs 低电平锁存 */ udelay(WS2812_RESET_US); /* 300 μs */ return0;}

    设计要点:write()全程持有dev->lock,且在拿锁后二次检查长度——防止拷贝数据期间有别的进程通过ioctl改了LED数量,导致编码越界。SPI核心层用"消息(message)→ 传输(transfer)"两级结构描述一次通信,即使只有一段transfer,也必须走init → add_tail → sync的流程。

    5.2 ioctl():动态调整灯珠数量

    命令码通过_IOW/_IOR宏打包为32 bit,内核可在调用前校验方向与数据长度,减少越界风险:

    #defineWS2812_IOC_MAGIC 'W'#defineWS2812_IOC_SET_COUNT _IOW(WS2812_IOC_MAGIC,1, u32) /* 设置灯珠数 */#defineWS2812_IOC_GET_COUNT _IOR(WS2812_IOC_MAGIC,2, u32) /* 读取灯珠数 */

    5f4b4f98-b272-11f1-ab55-92fbcf53809c.png

    SET_COUNT的实现体现了"先校验、再加锁、后变更"的 ioctl 写法,配套ws2812_realloc_bufs()重新分配发送缓冲与回读缓冲。

    06

    设备树配置与加载验证

    驱动通过compatible属性与设备树节点匹配,在SPI控制器节点下添加灯带节点即可(以spi0、CS0为例):

    &spi0 { ws2812@0{ compatible ="worldsemi,ws2812-spi"; reg = <0>; spi-max-frequency = <3200000>; led-count = <24>; };};

    各属性说明:

    5f53af80-b272-11f1-ab55-92fbcf53809c.png

    注意:

    led-count是必填项,驱动读不到直接返回-ENODEV

    spi-max-frequency不可随意更改:它直接决定码元宽度,修改后时序将不合规;

    SPI core匹配时modalias会剥掉厂商前缀变为ws2812-spi,因此驱动里同时提供同名的spi_device_id表以支持模块自动加载。

    板端验证:

    # 点红灯(第一颗)printf'\xff\x00\x00'> /dev/ws2812_spi0# 回读当前灯珠状态hexdump -C /dev/ws2812_spi0# 查看内核日志dmesg | grep ws2812

    写在最后

    综上所述,这套方案的核心价值在于把"时序生成"和"数据搬运"两件事都交给了硬件:SPI控制器负责产生精确波形,其精度只取决于晶振而非CPU调度;DMA负责在数据超过FIFO阈值时自动搬运;CPU仅承担RGB编码这一件事,且在长灯带场景下编码完成后即可转去执行其他任务。

    这种"用硬件外设替代软件bit-bang"的思路,在嵌入式驱动开发中具有普适性——无论是SK6812、DHT11,还是红外NEC协议,凡是依赖严格时序的单总线设备,都可以复用"硬件外设造时序 + DMA解放CPU"这一范式。工程师需要做的,只是找到合适的外设时钟频率与码元映射关系。