简介:这份资源是面向嵌入式开发者与电子爱好者的单线总线RGB LED驱动方案,源自一个团队设计项目的子模块,核心在于用改进型1-wire协议驱动自带电源线的RGB LED。它针对标准1-wire协议上拉电阻限制电流、无法驱动较大负载的痛点,改用P沟道MOSFET短时切断主端供电,牺牲双向通信能力换取更大电流供给,适合需要多灯级联又受限于布线的场景。压缩包共17个文件,约19KB,包含3个C源文件与2个头文件构成驱动与主逻辑,2个库文件及原理图、PCB、工程文件用于硬件设计,另有Makefile、颜色配置脚本与说明文档,软硬件资料齐全。目前已有406人学习下载。读者可从中获得完整的协议改造思路、从设备电容储能设计要点、可复用的驱动代码与KiCad工程文件,便于直接移植到自己的单线LED项目中,理解单向大电流总线的取舍与实现细节。
1. 单线总线驱动 RGB LED:一根线到底能挂多少颗灯
如果你手头只有一根数据线,却要串起几十颗 RGB LED,还要每颗独立调色,那 onewire-leds 这个方向就值得花时间。它讲的不是那种带时钟线的 SPI 灯带,而是把数据、时序、刷新全部压到一根信号线上,靠精确的脉宽编码把 24 位颜色值逐颗推下去。我第一次接触这类方案是在一个灯板项目里,主控引脚只剩一个空闲,灯却有 48 颗,当时觉得这事要么做不了,要么得加芯片。后来发现,只要把单线总线的时序吃透,一根线确实能挂不少灯,而且刷新率还能接受。
它适合谁?做小型灯效装置、模型灯、键盘背光、状态指示环的嵌入式开发者,尤其是引脚资源紧张、又不想上专用驱动芯片的场景。核心难点不在协议本身,而在时序容差、线缆长度、供电压降这三件事上。下面按“先立住原理,再动手复现,最后讲坑”的顺序拆开讲。
2. 单线归零码协议:RGB LED 驱动器到底在传什么
2.1 归零码怎么把 0 和 1 塞进一根线
单线总线驱动 RGB LED 最常见的方式是归零码,英文常写作 RZ 编码。它的思路很直接:每一位数据都由一个高电平加一个低电平组成,区别在于高电平持续多久。高电平长、低电平短,代表 1;高电平短、低电平长,代表 0。接收端每来一个位,先测高电平宽度,再等低电平结束,就能判断这一位是 0 还是 1。
以常见的 800kHz 类时序为例,一个位周期大约 1.25 微秒。0 码的高电平约 0.3 微秒,低电平约 0.9 微秒;1 码的高电平约 0.6 微秒,低电平约 0.6 微秒。不同厂家芯片的阈值略有差异,但大体落在这个范围。真正要命的是,接收芯片通常只允许正负 150 纳秒左右的偏差,超过就可能把 1 认成 0,或者整帧错位。
RGB LED 每颗需要 24 位,顺序一般是 G、R、B 各 8 位,高位先发。发完 24 位后,芯片把颜色锁存到输出,然后把数据整形后转发给下一颗。这就是为什么它能串起来:每颗灯吃掉自己的 24 位,剩下的往后传。整条链发完一帧后,需要一个超过 50 微秒的低电平作为复位,所有灯同时更新。
2.2 为什么不用 SPI 而选单线
SPI 方案需要时钟线和数据线,至少两根,而且灯带本身通常也带时钟。单线方案的优势是省引脚、省线材、接线简单,尤其适合主控引脚被占满的情况。代价是时序完全靠软件或 PWM 加 DMA 来生成,对中断延迟非常敏感。
我一般会这样选:如果主控有闲置的 SPI 加 DMA,而且灯带支持 SPI 时序,优先用 SPI,因为硬件帮你扛时序,CPU 几乎不参与。如果引脚只剩一个,或者灯带就是单线归零码,那就老老实实做单线驱动。单线不是不能用,而是要把“时序不能被中断打断”这件事当成硬约束。
2.3 最小可复现的发送函数长什么样
下面这段是单线发送的核心逻辑,用 GPIO 翻转加忙等实现,适合主频足够高、能关中断的 MCU。它不是最优实现,但最容易看懂时序。
// 单线归零码发送一个字节,高位先发 // T0H/T0L/T1H/T1L 需要按主频换算成循环次数 void ow_send_byte(uint8_t b) { for (int i = 7; i >= 0; i--) { if (b & (1 << i)) { GPIO_SET(); // 拉高 delay_cycles(T1H); // 1 码高电平 GPIO_CLR(); // 拉低 delay_cycles(T1L); // 1 码低电平 } else { GPIO_SET(); delay_cycles(T0H); // 0 码高电平 GPIO_CLR(); delay_cycles(T0L); // 0 码低电平 } } } // 发送一颗 RGB LED 的 24 位,顺序 G-R-B void ow_send_rgb(uint8_t g, uint8_t r, uint8_t b) { ow_send_byte(g); ow_send_byte(r); ow_send_byte(b); } // 整条链刷新:发完所有灯后拉低至少 50us 复位 void ow_flush(uint8_t *buf, int led_count) { __disable_irq(); // 关键:发送期间关中断 for (int i = 0; i < led_count; i++) { ow_send_rgb(buf[i*3], buf[i*3+1], buf[i*3+2]); } __enable_irq(); delay_us(60); // 复位低电平 }逻辑说明:ow_send_byte按位判断,1 码和 0 码分别走不同的高电平延时。ow_send_rgb固定 G-R-B 顺序,如果你的灯是 R-G-B,把参数顺序换掉即可。ow_flush在发送期间关中断,这是单线方案能不能稳定的分水岭。
参数说明:T0H、T0L、T1H、T1L不是随便填的,要用主频和实际芯片手册的纳秒值换算。比如主频 72MHz,一个周期约 13.9 纳秒,0.3 微秒约等于 22 个周期。delay_cycles最好用内联汇编或__NOP()循环,别调用普通函数,否则函数调用开销会把时序撑爆。复位低电平我一般给 60 微秒以上,留一点余量。
3. 把 48 颗灯跑起来:缓冲区、刷新率与供电的三角关系
3.1 帧缓冲区怎么组织才不浪费 RAM
48 颗灯,每颗 3 字节,一共 144 字节。这个量很小,直接开一个uint8_t buf[144]就行。但如果你要做的灯效需要双缓冲,比如一边刷新一边计算下一帧,那就得开两份,288 字节。对于 RAM 只有几 KB 的小 MCU,这个开销要算清楚。
我一般会这样组织:用一维数组按灯序存放,索引i*3+0是 G,i*3+1是 R,i*3+2是 B。不要用结构体数组,因为结构体可能有对齐填充,反而浪费。发送时直接按这个顺序推,不需要额外转换。
#define LED_COUNT 48 uint8_t frame[LED_COUNT * 3]; // 设置第 n 颗灯的颜色 void set_led(int n, uint8_t g, uint8_t r, uint8_t b) { if (n < 0 || n >= LED_COUNT) return; frame[n*3 + 0] = g; frame[n*3 + 1] = r; frame[n*3 + 2] = b; }逻辑说明:set_led只做边界检查和写入,不做任何发送动作。发送统一由ow_flush在帧计算完成后调用。这样灯效计算和总线发送解耦,逻辑更清晰。
参数说明:LED_COUNT按实际灯数改。如果你要双缓冲,再开一个frame_next,计算完后用指针交换,避免整块拷贝。
3.2 刷新率能到多少,算一笔账
单线归零码每一位 1.25 微秒,一颗灯 24 位,就是 30 微秒。48 颗灯一帧 1440 微秒,约 1.44 毫秒。加上复位 60 微秒,一帧约 1.5 毫秒。理论上刷新率能到 600Hz 以上。但实际做不到,因为你还得留时间给灯效计算、通信、其他任务。
我实测下来,48 颗灯用忙等发送,CPU 占用大概在 8% 到 12% 之间(主频 72MHz)。如果你把刷新率定在 100Hz,也就是每 10 毫秒刷一帧,发送只占 1.5 毫秒,剩下 8.5 毫秒给别的任务,完全够用。但如果你定到 400Hz,发送就占 6 毫秒,CPU 会明显吃紧。
这里有个反直觉的点:刷新率不是越高越好。人眼对 100Hz 以上的灯效变化已经不太敏感,除非你做高速扫描或者拍照同步。我一般把刷新率定在 100 到 200Hz 之间,既流畅又留足 CPU。
3.3 供电压降:为什么最后一颗灯总是偏色
48 颗灯全白,每颗约 60 毫安,总电流接近 3 安。如果你用细线或者长线,末端电压会明显低于首端。RGB LED 对电压很敏感,电压低的时候,蓝色和绿色先暗,红色相对坚挺,结果就是末端偏红。
解决思路有三个:第一,电源线和地线加粗,最好每 16 颗灯就近补一次电;第二,不要长时间全白,灯效里控制最大亮度;第三,在固件里做亮度补偿,末端灯适当提高数值。我一般会先量电压,首端和末端差超过 0.3V 就要补电,别指望固件能救回来。
提示:补电时,电源正极和地线都要补,只补正极会导致地线压降,信号参考电平漂移,反而更容易出错。
4. 时序、中断与线缆:单线驱动最容易翻车的四个地方
4.1 现象:灯随机闪烁或整条不亮
原因:发送期间被中断打断,某一位的高电平被拉长,接收芯片把 0 认成 1,后面所有位全部错位。这种错误一旦发生,整帧数据都废了,表现就是随机闪烁或者干脆不亮。
解决:发送期间关中断,或者用 DMA 加 PWM 硬件生成时序。关中断的时间要算清楚,48 颗灯约 1.5 毫秒,这个时间内不能响应任何中断。如果你的系统有必须响应的中断,比如串口接收,那就把发送拆成多段,每段之间开中断,但这样会引入间隙,需要确认接收芯片能容忍。
4.2 现象:第一颗灯正常,后面的灯颜色错乱
原因:灯与灯之间的数据整形有延迟,或者线太长导致信号边沿变缓。单线归零码对边沿要求高,线超过一米就容易出问题。
解决:缩短线长,或者在每颗灯之间加缓冲。实际项目中,我一般把线控制在 50 厘米以内,超过就考虑分组,每组单独一根数据线。另外,数据线最好远离电源线,避免耦合干扰。
4.3 现象:上电后第一帧颜色不对,之后正常
原因:上电时灯的内部寄存器状态不确定,第一帧数据可能被误解释。有些灯需要额外的复位时间才能进入接收状态。
解决:上电后先发一帧全零,再发正常帧。或者在初始化时多等一段时间,比如 10 毫秒,让灯内部电路稳定。这个坑很隐蔽,因为只影响第一帧,很多人会忽略。
4.4 现象:低亮度下颜色跳变,高亮度正常
原因:低亮度时,PWM 占空比很小,归零码的时序容差相对更紧,加上电源纹波,容易导致低位数据出错。
解决:低亮度时不要用太小的数值,或者做伽马校正,把低亮度区间拉开。另外,电源上加滤波电容,每 8 颗灯并一个 100nF 加一个 10uF,能明显改善。
5. 进阶技巧:用 DMA 加 PWM 把 CPU 解放出来
如果你已经跑通了忙等版本,下一步大概率会碰到 CPU 占用高的问题。这时候可以用定时器 PWM 加 DMA 来生成归零码。思路是:把每一位的高低电平时间换算成 PWM 的占空比和周期,用 DMA 把一组比较值搬到定时器寄存器,硬件自动输出波形,CPU 完全不参与。
具体做法因 MCU 而异,但通用步骤是:选一个定时器,配置成 PWM 输出模式,时钟频率要足够高,比如 72MHz 或 144MHz。把 0 码和 1 码分别对应两个比较值,然后为每一位准备两个 DMA 传输项:一个设周期和占空比,一个设下一个周期和占空比。48 颗灯 1152 位,需要 2304 个 DMA 项,听起来多,但对 DMA 来说不算什么。
// 伪代码:为每一位生成两个 DMA 项 // period 是位周期对应的定时器计数值 // duty0/duty1 是 0 码和 1 码的高电平计数值 void build_dma_table(uint8_t *buf, int led_count, uint16_t *table) { int idx = 0; for (int i = 0; i < led_count * 3; i++) { for (int bit = 7; bit >= 0; bit--) { int is_one = (buf[i] >> bit) & 1; table[idx++] = is_one ? duty1 : duty0; // 高电平比较值 table[idx++] = period; // 周期重载值 } } }逻辑说明:每个位需要两次 DMA 写入,第一次写比较值决定高电平宽度,第二次写周期值决定位周期。DMA 循环搬完整个表,硬件自动输出。发送期间 CPU 可以做别的事,只需要在最后等一个传输完成标志,然后拉低复位。
参数说明:period要按定时器频率和 1.25 微秒换算。duty0和duty1按 0.3 微秒和 0.6 微秒换算。DMA 表的大小是led_count * 3 * 8 * 2个uint16_t,48 颗灯就是 2304 个,约 4.6KB,RAM 要留够。如果 RAM 紧张,可以分块发送,但分块之间会有间隙,需要确认灯能容忍。
这个方案的好处是 CPU 占用几乎为零,刷新率可以做到很高,而且不怕中断打断。代价是 RAM 开销和初始化复杂度。我一般会在项目后期再上 DMA,前期先用忙等把逻辑跑通,确认灯效和协议没问题,再换硬件方案。
最后说一个我自己的习惯:每次改完时序参数,先用逻辑分析仪抓一帧,量一下 0 码和 1 码的高电平宽度,确认在芯片手册范围内。别靠肉眼判断灯的颜色来调时序,那是玄学,抓波形才是硬道理。希望帮到你。
本文还有配套的精品资源,点击获取