ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

FPGA驱动WS2812B点阵屏:Verilog状态机实现16x32滚动文字显示

FPGA驱动WS2812B点阵屏:Verilog状态机实现16x32滚动文字显示 之前接了个活儿客户要做一块 16x32 的 WS2812B 点阵屏用来滚动显示标语。我一开始图省事直接上了 STM32写定时器DMA发送结果只要系统里一开中断密集的任务灯珠就开始闪严重的时候整屏花掉。后来一怒之下换成 FPGA Verilog把所有时序放到独立状态机里一次点亮再也没出过幺蛾子。这篇文章就把这套方案从头到尾拆开讲WS2812B 的单线协议、点阵硬件怎么接、Verilog 模块怎么划分、完整代码怎么理解以及上板调试时最容易踩的几个坑。适合手里正好有 FPGA 开发板、想驱动灯带灯屏的朋友也适合想系统学一下单线协议时序状态机写法的人。文中代码基于 50MHz 时钟编写其他频率的板子只需按公式改几个参数。1. 为什么我最终用 FPGA 驱动 WS2812B 点阵而不是继续用单片机1.1 单片机的短板不是性能不够而是时序会被打断WS2812B 的单线协议对时序要求其实非常苛刻。每一个 bit 的周期是 1.25us高电平宽度决定了这一位是 0 还是 10码高电平只有 220~380ns1码高电平有 580~1000ns窗口本身不算宽。单片机如果只是原地发灯DMA 或者定时器完全可以胜任可一旦你的系统还要处理 WiFi、按键扫描、传感器数据、动画运算哪怕只有一个高优先级中断插进来几十微秒这一帧的时序就全乱了。灯珠不会报错只会用花屏和错位向你抗议。FPGA 的思维方式不一样。我把“发送每一位、每一帧”的逻辑做成了一个独立状态机它只认时钟沿不关心系统里还有没有别的事情在跑。启动之后每一个时钟周期该拉高就拉高该拉低就拉低误差只有一个时钟周期稳定得像瑞士钟表。对于灯屏这种本质上是“精确时序 大量重复搬运”的场景FPGA 天然合适。1.2 先算一笔账16x32 点阵的刷新率需求很多人以为 WS2812B 点阵只要有数据点亮就行忽略了它是一个“串行链”。每一颗灯不仅要接收自己的 24bit 数据还要把后续的数据继续往下传。所以一帧完整的画面就是把整条链上所有灯的数据依次发一遍最后给一个超过 280us 的低电平复位信号整帧才真正锁存显示。以本文的 16 行 x 32 列共 512 颗灯为例每颗灯需要 24bit 数据每个 bit 周期约 1.2us单颗灯耗时 24 x 1.2us 28.8us512 颗灯耗时 512 x 28.8us 14.75ms加上帧末尾 280us 复位约 15ms 完成一次整帧刷新换算下来刷新率大概是 66Hz超过 60Hz 的人眼无闪烁阈值完全可以接受。这个刷新率用单片机也能做到但代价是你得保证 15ms 内任何中断都不能干扰发送。FPGA 就没有这个心理负担状态机一旦开始推 high/low其他逻辑忙成什么样都不影响它。2. WS2812B 协议拆解与点阵硬件连接2.1 单线时序0码、1码、复位到底长什么样WS2812B 只有一根数据线靠高电平宽度表示 0 和 1靠持续低电平表示一帧结束。下面这张表是标准的时序参数信号高电平时间低电平时间说明0 码220~380ns580~1000ns高电平短、低电平长1 码580~1000ns220~380ns高电平长、低电平短RESET低电平持续超过 280us整帧结束后触发锁存数据发送顺序是 GRB也就是先发绿色分量再发红色分量最后发蓝色分量每个分量高位在前。这一点特别容易踩坑后面调试部分我会再强调。FPGA 端如果用 50MHz 时钟周期 20ns几个关键参数可以这样取0 码高电平15 个时钟周期 300ns落在 220~380ns 区间0 码低电平45 个时钟周期 900ns落在 580~1000ns 区间1 码高电平45 个时钟周期 900ns落在 580~1000ns 区间1 码低电平15 个时钟周期 300ns落在 220~380ns 区间帧复位14000 个时钟周期 280us这套参数我实测过兼容常见批次的 WS2812B也兼容很多国产替代型号。2.2 512 颗灯的级联顺序与像素地址对照点阵的物理连接方式决定了 Verilog 里“像素地址”怎么算。我的做法是行主序级联第一行从左到右接 32 颗第一行末尾接到第二行开头以此类推。发送端从链首开始先发完第一行 32 颗再发第二行把 512 颗全部发完。对应到代码里用一个 pix_cnt 计数取值范围 0~511pix_cnt[8:5] 是行号范围 0~15pix_cnt[4:0] 是列号范围 0~31这样一行一列正好对应一块 16x32 点阵。如果你想做 8x64 或者 32x64 的屏只需要改 ROWS/COLS 参数并保证物理级联顺序和地址映射一致就行。2.3 电源和电平转换这块最容易翻车512 颗 WS2812B 全亮白色时单颗电流按 60mA 估算总电流高达 30A 左右。实际显示文字时大部分灯是熄灭的电流通常只有几安培但你不能按“通常”来设计电源。我的建议是用 5V/10A 以上的电源起步如果可能全屏显示直接上 5V/20A电源和灯板之间走粗线最好在电源入口并联 1000uF 电解电容FPGA 板子和灯板必须共地否则信号电平参考不一致会随机花屏另一个隐藏问题是逻辑电平。FPGA 的 IO 通常是 3.3V而老款 WS2812B 在 5V 供电下输入高电平阈值约为 0.7 x VDD 3.5V。3.3V 输出严格来说是不够的。很多开发板直驱也能用那是因为灯珠内部存在一定的施密特整形但我劝你不要赌。稳妥方案是加一颗 74AHCT125 或 74HCT245 做电平转换把 3.3V 信号抬到 5V。如果你用的是新型号 WS2812B-V5输入阈值做了优化3.3V 直驱才比较安全。3. 系统架构模块划分与状态机设计3.1 四个模块各管什么这套 Verilog 工程可以拆成四块字模 ROM存放 16x16 汉字的点阵数据每个字占 16 行每行 16bit帧缓冲一块 16 行 x 32 列的 1bit 存储器记录当前屏幕每个点亮还是灭WS2812B 发送引擎把帧缓冲里的每个点转换成 24bit GRB 颜色再按单线协议一位一位发出去滚动控制器决定每隔多久把窗口往右移一列并重新加载帧缓冲这个划分的好处是职责单一。字模数据和点灯时序完全解耦以后你哪怕不做文字滚动改成显示摄像头图片也只需要换掉帧缓冲的填充来源发送引擎一个字都不用动。3.2 为什么帧复位必须放在帧末而不是每颗灯之后这里有一个非常关键的设计决策。很多人第一次写 WS2812B 的 FPGA 驱动习惯让发送模块发完一颗灯的 24bit 后就进入 280us 复位然后发下一颗。结果刷新率惨不忍睹正确方案发送完 512 颗灯的数据后只做一次 280us 复位帧耗时约 15ms刷新率 66Hz错误方案每颗灯发送后都做复位单颗耗时变成 28.8us 280us ≈ 309us512 颗总耗时约 158ms刷新率只剩 6Hz6Hz 刷新率是什么概念就是你肉眼看屏幕明显在一闪一闪完全没法用。WS2812B 的复位信号只需要在整帧末尾出现一次它本身就承担“锁存整帧”的功能。所以我把复位逻辑放在顶层状态机的帧末尾发送模块只负责连续吐 24bit 数据不负责复位。3.3 滚动一列的数据流窗口偏移与帧加载时机动态文字滚动的核心是理解“窗口”这个概念。我的文本宽度是 4 个字共 64 列屏幕只能显示 32 列所以窗口偏移 window_off 在 0~32 之间循环。每次窗口偏移加 1屏幕显示的内容就相当于文字向左移动一列。滚动更新发生在帧复位结束之后先把 window_off 加 1然后立即用新的窗口位置重新计算整块 16x32 帧数据写入帧缓冲再进入一个延时状态让这帧画面稳定显示一段时间。这样画面不是每 15ms 刷新一次就滚动一次而是每 80ms 左右滚动一列视觉效果比较自然。如果每帧都滚动文字会快得看不清。延时的时间由参数 SCROLL_CYCLES 控制。4. 完整 Verilog 代码WS2812B 时序发送模块4.1 时序参数在 50MHz 时钟下的换算逻辑发送模块里用了一组参数 T0H、T0L、T1H、T1L分别表示 0 码高电平、0 码低电平、1 码高电平、1 码低电平的时钟周期数。如果你的 FPGA 主时钟不是 50MHz换算公式很简单时钟周期 T 1000 / f_MHz单位 ns需要的时钟周期数 目标时间(ns) / T取整数比如用 100MHz 时钟想得到 300ns 高电平就是 300 / 10 30 个周期。用 75MHz 时钟200ns 就是 15 个周期。改板子时钟的时候记得把这四个参数和帧复位周期一起改掉。4.2 ws2812b_tx.v 完整代码// ws2812b_tx.v // 发送单个像素的24bit GRB数据发送完成后回到IDLE // 注意本模块不负责帧级RESET帧复位由顶层在整帧数据发完后统一产生 module ws2812b_tx #( parameter T0H 15, parameter T0L 45, parameter T1H 45, parameter T1L 15 )( input wire clk, input wire rst_n, input wire [23:0] rgb, // 输入为RGB888rgb[23:16]R, rgb[15:8]G, rgb[7:0]B input wire start, // 单周期启动脉冲busy0时有效 output reg dout, output wire busy ); localparam S_IDLE 2d0; localparam S_HIGH 2d1; localparam S_LOW 2d2; reg [1:0] state; reg [4:0] bit_cnt; reg [13:0] tcnt; // WS2812B 实际数据顺序是 GRBMSB先发 wire [23:0] grb {rgb[15:8], rgb[23:16], rgb[7:0]}; wire cur_bit grb[23 - bit_cnt]; assign busy (state ! S_IDLE); always (posedge clk or negedge rst_n) begin if (~rst_n) begin state S_IDLE; dout 1b0; bit_cnt 5d0; tcnt 14d0; end else begin case (state) S_IDLE: begin if (start) begin bit_cnt 5d0; dout 1b1; tcnt grb[23] ? T1H : T0H; state S_HIGH; end end S_HIGH: begin if (tcnt 14d0) begin dout 1b0; tcnt cur_bit ? T1L : T0L; state S_LOW; end else begin tcnt tcnt - 14d1; end end S_LOW: begin if (tcnt 14d0) begin if (bit_cnt 5d23) begin state S_IDLE; end else begin bit_cnt bit_cnt 5d1; dout 1b1; tcnt grb[23 - (bit_cnt 5d1)] ? T1H : T0H; state S_HIGH; end end else begin tcnt tcnt - 14d1; end end default: state S_IDLE; endcase end end endmodule4.3 这个状态机是怎么把“每一位”发出去的整个发送过程可以理解成一个“节拍器”。IDLE 状态收到 start 信号后先取出第 23 位数据GRB 的最高位根据它是 0 还是 1把高电平计时器设成 T0H 或 T1H然后进入 HIGH 状态。HIGH 状态里计时器每个时钟减 1减到 0 就把信号拉低再根据当前位设置低电平时长进入 LOW 状态。LOW 结束之后如果 24bit 没发完就把 bit_cnt 加 1拉高信号继续发下一位。发完第 23 位也就是最后一位直接回到 IDLE等待下一个像素。这里有个细节值得说输入 rgb 是常规的 RGB888但 WS2812B 要求先发 G 再发 R 再发 B。代码里用{rgb[15:8], rgb[23:16], rgb[7:0]}这一个拼接操作完成重排。如果忽略这一步最常见的现象就是红色和绿色互换蓝色也可能错位。5. 完整 Verilog 代码字模 ROM 滚动显示顶层5.1 字模数据怎么组织和替换字模 ROM 我设计成char_rom[字符编号][行号]每个元素是 16bitbit15 对应该字符最左边一列。初始化时我先用“中”的字形填充所有字符再把第 1 和第 3 个字符的中间一行改成全亮变成“日”。这样滚动文本就是“中日中日”。实际项目中你肯定要换自己的文字。用取模软件比如 PCtoLCD2002导出 16x16 汉字点阵时选择“横向取模、行扫描”得到的是每行 16bit 数据。把它们按行填入对应的 char_rom 数组即可。如果取模软件输出的是字节形式两个字节拼成一个 16bit 数注意高低字节顺序要和你的行定义一致。替换字模不需要改发送逻辑只需要改 initial 里的数据以及把 CHAR_NUM 改成实际的字符个数。5.2 ws2812b_top.v 完整代码// ws2812b_top.v // 16行 x 32列 WS2812B点阵动态文字显示 // 点阵物理连接每行从左到右行与行首尾相连 // 像素地址 row*32 col发送顺序从地址0开始 module ws2812b_top #( parameter SCROLL_CYCLES 4000000 // 50MHz时钟下约80ms滚动一列 )( input wire clk, input wire rst_n, output wire led_data ); localparam ROWS 16; localparam COLS 32; localparam CHAR_NUM 4; // 滚动文本共4个汉字 localparam TEXT_COLS CHAR_NUM * 16; localparam PIX_NUM ROWS * COLS; localparam COLOR_ON 24h00FF00; // 文字点亮颜色绿色 localparam COLOR_OFF 24h000000; // 背景颜色熄灭 // 字模ROM4个汉字每个汉字16行每行16bitbit15对应最左列 reg [15:0] char_rom [0:CHAR_NUM-1][0:15]; integer i; initial begin // 先用“中”的字形填充所有字符 for (i 0; i CHAR_NUM; i i 1) begin char_rom[i][0] 16h1FFC; char_rom[i][1] 16h1088; char_rom[i][2] 16h1088; char_rom[i][3] 16h1088; char_rom[i][4] 16h1088; char_rom[i][5] 16h1088; char_rom[i][6] 16h1088; char_rom[i][7] 16h1088; char_rom[i][8] 16h1088; char_rom[i][9] 16h1088; char_rom[i][10] 16h1088; char_rom[i][11] 16h1088; char_rom[i][12] 16h1088; char_rom[i][13] 16h1088; char_rom[i][14] 16h1088; char_rom[i][15] 16h1FFC; end // 第1和第3个字符改成“日”在中间加一横 char_rom[1][7] 16h1FFC; char_rom[3][7] 16h1FFC; end // 帧缓冲1bit图案frame[行][列] reg frame [0:ROWS-1][0:COLS-1]; reg [5:0] r_load, c_load; // WS2812B发送模块 wire tx_busy; reg [23:0] tx_rgb; reg tx_start; wire tx_dout; assign led_data tx_dout; ws2812b_tx #( .T0H(15), .T0L(45), .T1H(45), .T1L(15) ) u_tx ( .clk (clk), .rst_n (rst_n), .rgb (tx_rgb), .start (tx_start), .dout (tx_dout), .busy (tx_busy) ); // 根据文本列号和行号从字模ROM中取一个点 function automatic get_char_bit; input [5:0] text_col; input [3:0] row; begin get_char_bit char_rom[text_col[5:4]][row][15 - text_col[3:0]]; end endfunction // 帧更新脉冲 reg frame_update; reg frame_update_d; wire frame_update_pulse frame_update ~frame_update_d; // 滚动窗口偏移0 ~ 32 reg [5:0] window_off; always (posedge clk) begin frame_update_d frame_update; end // 帧加载在滚动偏移更新后把新窗口的内容写入帧缓冲 always (posedge clk or negedge rst_n) begin if (~rst_n) begin for (r_load 0; r_load ROWS; r_load r_load 1) for (c_load 0; c_load COLS; c_load c_load 1) frame[r_load][c_load] 1b0; end else if (frame_update_pulse) begin for (r_load 0; r_load ROWS; r_load r_load 1) for (c_load 0; c_load COLS; c_load c_load 1) frame[r_load][c_load] get_char_bit(window_off c_load, r_load[3:0]); end end // 顶层状态机 localparam S_IDLE 3d0; localparam S_TX_START 3d1; localparam S_TX_WAIT 3d2; localparam S_TX_DONE 3d3; localparam S_FRAME_RESET 3d4; localparam S_SCROLL 3d5; reg [2:0] state; reg [9:0] pix_cnt; reg [13:0] frame_rst_cnt; reg [26:0] scroll_cnt; always (posedge clk or negedge rst_n) begin if (~rst_n) begin state S_IDLE; pix_cnt 10d0; tx_start 1b0; tx_rgb 24h000000; window_off 6d0; frame_update 1b0; frame_rst_cnt 14d0; scroll_cnt 27d0; end else begin tx_start 1b0; case (state) S_IDLE: begin if (~tx_busy) begin pix_cnt 10d0; state S_TX_START; end end S_TX_START: begin if (pix_cnt PIX_NUM) begin tx_rgb frame[pix_cnt[8:5]][pix_cnt[4:0]] ? COLOR_ON : COLOR_OFF; tx_start 1b1; state S_TX_WAIT; end else begin // 一帧数据已经全部发完进入帧级RESET frame_rst_cnt 14d14000; // 280us 50MHz state S_FRAME_RESET; end end S_TX_WAIT: begin if (tx_busy) begin tx_start 1b0; state S_TX_DONE; end end S_TX_DONE: begin if (~tx_busy) begin pix_cnt pix_cnt 10d1; state S_TX_START; end end S_FRAME_RESET: begin if (frame_rst_cnt 14d1) begin // 280us复位结束移动滚动窗口并请求加载新帧 frame_update 1b1; window_off (window_off TEXT_COLS - COLS) ? 6d0 : window_off 6d1; scroll_cnt 27d0; state S_SCROLL; end else begin frame_rst_cnt frame_rst_cnt - 14d1; end end S_SCROLL: begin frame_update 1b0; if (scroll_cnt SCROLL_CYCLES) begin state S_IDLE; end else begin scroll_cnt scroll_cnt 27d1; end end default: state S_IDLE; endcase end end endmodule5.3 滚动窗口偏移计算的代码解读理解这段代码的钥匙是 get_char_bit 函数里的索引拆分。text_col 是 0~63 的文本列号text_col[5:4] 取出高 2 位正好对应 4 个字符之一的编号text_col[3:0] 取出低 4 位对应字符内的列号 0~15。窗口偏移 window_off 加上屏幕列号 c_load就得到当前屏幕这一列在整条文本里的真实列号。每次帧更新后帧缓冲记录的就是“当前窗口下”的 16 行 32 列图案。发送引擎只认帧缓冲不关心窗口怎么移动所以滚动逻辑和发送逻辑互不干扰。如果你不想用 window_off 滚动而是希望文字瞬间切换只需要把更新帧的时机改一下比如在 S_FRAME_RESET 里固定 window_off 为一个常量。6. 上板调试全流程与高频故障排查6.1 三步点亮从单灯到全屏不要一上来就烧整个 16x32 工程那样出了问题很难定位。我的调试顺序是先点一盏灯。把 PIX_NUM 临时改成 1COLOR_ON 改成 0x003300 这种暗绿色上电看第一颗灯是否亮。这一步验证的是时钟、复位、数据线连接和发送模块本身。如果第一颗都不亮先用万用表量 led_data 高电平再用示波器看波形大概率是时序参数或引脚约束问题。再试一行。把 COLS 保持 32ROWS 改为 1让整行 32 颗灯点亮。这一步验证链序是否接对。如果亮的灯顺序是乱的检查物理连接方向和 pix_cnt 地址映射。最后上完整工程。确认 512 颗灯整帧显示和滚动逻辑正常。这个时候如果出现问题基本可以锁定为功耗或帧复位相关。6.2 我遇到过的五个诡异现象现象可能的根因解决办法全屏不亮复位时间不足、电源没共地确认帧末低电平超过 280us检查 GND 连接第一颗亮后面全不亮链序方向接反、最后一颗灯之后被截断核对级联顺序和 pix_cnt 映射红色和绿色互换数据顺序没有按 GRB 重排检查 grb 拼接表达式亮度一高就花屏电源压降严重、3.3V 直驱 5V 灯珠换粗电源线、加电容、加电平转换文字往右滚而不是往左滚字模 bit 顺序定义反了把取模方向和 bit15 的映射对调其中最坑的是“亮度一高就花屏”。刚开始我用全亮绿色测试颜色一上来几百颗灯同时拉电流电源电压从 5.0V 跌到 4.3V结果整片乱闪。这不是代码问题是电源问题。后来我把测试颜色调暗再把电源线加粗问题立刻消失。6.3 调参建议与后续扩展思路滚动速度主要由 SCROLL_CYCLES 控制。在 50MHz 时钟下每 1_000_000 个周期约等于 20ms。我默认写的 4_000_000 就是 80ms 滚动一列适合汉字标语。如果想让文字流动更跟手可以改成 2_000_000如果想要慢速展示改成 8_000_000 也行。颜色想换成暖色直接把 COLOR_ON 改成 0xFF3000 之类的值。调试阶段建议把颜色调暗比如 0x001000既保护眼睛又减小电源压力。这套架构扩展性很好。想显示更多字扩大 char_rom 的字符数量同时把 TEXT_COLS 和窗口偏移位数一并扩大想让文字渐变更炫可以把帧缓冲从 1bit 扩展成 8bit 灰度发送模块增加 PWM 灰度合并逻辑。这些都是在这套骨架上做加法底层的 WS2812B 发送引擎不需要动。最后分享一个我自己的习惯所有 WS2812B 项目的验证顺序都是“先暗色、先单颗、先短链”每次只改动一个变量。按照这个节奏绝大多数问题都能在十分钟内定位到是供电、时序还是地址映射。这篇代码你可以直接抄去用但更建议改一改把字模换成你自己的词把滚动速度调到你喜欢的节奏这会逼你把每个模块都理解到位。玩灯最开心的就是看到自己写的状态机把一整排灯稳稳点亮那一刻祝你一次点亮。
返回列表