周末无聊, 之前有买来做手台的 rp2040 开发板, 拿来玩玩看.
超小一个, 商家给的资料全鬼扯, 最后在京东上找到了相同的板子, 引脚定义如下:
片上只有一个 WS2812 的 LED 灯, 首要目的就是点亮他. 参考文档如下:
- Getting started with Raspberry Pi Pico-series
- RP2040 Datasheet
- Github pico-examples/pio/ws2812
- WS2812 Datasheet
新建工程
首先跟着新手教程装 vscode 插件, 选择创建 C/C++ 工程.
Features 勾选会把对应功能代码嵌进去, 暂时用不到.
Stdio support 可以选择 stdio 输出到哪里, 教程 5.1 节有介绍:
串行输入(stdin)与输出(stdout)可以被重定向到 UART 串口与/或 USB CDC(USB串口).
当选择使用 UART 接口时, 输入与输出会通过设备上的 UART 接口发送, 默认使用 Pin 1 (GP0) 发送输出 (UART0_TX), 使用 Pin 2 (GP1) 用于接受输入 (UART0_RX).
我这板子没 debug 口, 创建工程时勾选 Console over USB 来发送调试信息给电脑.
烧写程序
先得能拿到调试信息, 就输出下开机时间吧.
1#include <stdio.h>
2#include "pico/stdlib.h"
3#include "pico/time.h"
4#include "hardware/clocks.h"
5
6int main()
7{
8 stdio_init_all();
9
10 while (true) {
11 absolute_time_t t = get_absolute_time();
12 uint32_t ms = to_ms_since_boot(t);
13 printf("Time since boot: %.3f s\n", ms / 1000.0);
14 sleep_ms(1000);
15 }
16}
按住 BOOTSEL/BOOT 按钮, 插上 USB, 此时 rp2040 应会以 USB 大容量存储设备出现在设备里, 如果有要写入的 uf2 文件直接拷贝进去就行, 这里我们可以直接在 vscode 侧边栏点击 Run Project (USB) 选项来编译并写入 rp2040.
写入后可以通过 vscode terminal 旁边的 serial monitor 工具接受串口信息, 不知道是哪个串口的话就拔插一下, 多了哪个就是哪个. 波特率 115200, 连接上去就能看到输出了.
接受这个串口数据的方法挺多, Windows 上可以用 Putty 终端, MacOS/Linux 可以用 screen 命令.
WS2812 传输协议
参考 WS2812 Datasheet, 它有个 DO 脚支持串联, 但是这里我用不到, 只考虑单个灯珠, 每次发送 24bit 颜色数据, 然后等待超过 50us 即可.
至于这 24bit 的发送时序, 参考文档下图:
bit 0 或 1 通过不同的脉冲表示, 具体的时间只要不超出规定的范围即可, 为了编程方便, 这里选定 T0H = 0.35us, T1H = T0L = TL = 0.7us.
Programmable Input Output
这部分相对复杂, 主要参考 RP2040 Datasheet 的第三章.
PIO 很适合用于处理通信, 这里要用它的原因是 WS2812 传输协议大多精确到纳秒, 但是 delay 仅有微秒的接口.
按照125MHz的时钟频率, 如果要休眠 0.7us 得写超多 nop 指令, 用 for 循环存在额外开销可能导致时间不准确, 所以还是用 PIO 啦.
简单来说, rp2040 片上有一些 State Machine, 可以负责数据的收发, 每个 State Machine 搭配一进一出两个 FIFO 队列用于数据交换. 编写好 State Machine 要执行的程序后, 只需要放入输出的 FIFO 队列, 数据就会自动发送出去.
每个 State Machine 上保存一个 Clock Div, 可用于分频, 将自身频率设置为不超过 rp2040 默认频率 125MHz 的值. 此外还有两个 Scratch 寄存器, X 和 Y, 可以用作 IN/OUT/SET/MOV 指令的目标, 以及 JMP 的条件.
先看 rp2040 手册第三章搞明白 PIO 的九个指令, 编写 PIO Assembly 如下, 保存为 ws2812.pio:
1.program ws2812
2
3.define public T0H 3 ; public 将宏定义写入生成的 header, 可以通过 ws2812_T0H 获取
4.define public T1H 6
5.define public TL 6
6
7.wrap_target
8 pull block ; 从 FIFO 读取数据到 OSR, 如果 FIFO 没有数据则阻塞
9 set x, 23 ; 循环 bitloop 24 次
10
11bitloop:
12 set pins, 1 ; 拉高引脚
13 out y, 1 [T0H - 3] ; 从 OSR 读取1位到 y
14 jmp !y skip ; 到这里是 T0H 个 cycle, 如果读0, 跳过下面这个 nop
15 nop [T1H - T0H - 1] ; 如果读到1, 保持到 T1H
16
17skip:
18 set pins, 0 [TL - 2] ; 拉低引脚, 保持到 TL
19 jmp x-- bitloop
20
21.wrap
pioasm 会根据 pio 文件生成 header 文件, vscode插件配置的 cmake 在 build 的时候会自动去跑, 不需要手动跑.
引脚定义显示 WS2812 在 GPIO16 上, 添加如下代码初始化 PIO, 其中 build/ws2812.pio.h 是 pioasm 生成的 header 文件.
1+#include "hardware/pio.h"
2+#include "build/ws2812.pio.h"
3
4 int main() {
5 stdio_init_all();
6
7+ uint sm = 0; // 选择 state machine
8+ PIO pio = pio0; // 创建 PIO 实例
9+ uint offset = pio_add_program(pio, &ws2812_program);
10+
11+ // 使用 GPIO16, 初始化并设置方向为 out
12+ uint pin = 16;
13+ pio_gpio_init(pio, pin);
14+ pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true);
15+
16+ pio_sm_config c = ws2812_program_get_default_config(offset);
17+ // 设置 set 指令的 pins 目标为 GPIO16
18+ sm_config_set_set_pins(&c, pin, 1);
19+ // 默认 out 指令从 LSB 开始取, 和 ws2812 的位顺序相反, 设置一下
20+ sm_config_set_out_shift(&c, false, true, 32);
21+ // 只用了发送, 接受的 FIFO 队列可以关掉
22+ sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX);
23+ // TL 占 0.7us, 设置分频
24+ float freq = 1.0 / 0.7e-6 * ws2812_TL;
25+ sm_config_set_clkdiv(&c, clock_get_hz(clk_sys) / freq);
26+
27+ pio_sm_init(pio, sm, offset, &c);
28+ pio_sm_set_enabled(pio, sm, true);
29
30+ // 给 FIFO 输入32位, 由于设置了 out shift 方向, 数据应位于高24位, 和WS2812读取顺序一致
31+ // 这里写0xf0是为了确认位顺序没问题, 如果反了会读到0x0f, 灯会很暗
32+ pio_sm_put_blocking(pio, sm, 0xf0 << 8);
33
34 // ...
35 }
烧写, 看到蓝灯较亮, 说明输出没问题了.
结尾
-
初始化的代码一般放在 pio 文件里, 用
% c-sdk {
和%}
括起来, 会放在生成的 header 里. -
side set
看 rp2040 指令定义, 所有指令都有 Delay/side-set 共用的5位, 不配置 side-set 的情况下全用作 Delay, 因此每个指令后面的中括号最大是 31.
side-set 可以将一些位用于命令执行时操作额外的引脚, 初始化时通过sm_config_set_sideset_pins
指定引脚, 直接在命令后面拼上side 0
就表示执行这个指令同时将给定引脚拉低. side-set 使 pioasm 更加灵活, 而且可以提高高速串口的频率. 上面的 WS2812 例子可以改成这样:1.program ws2812 2 3; 启用 side-set, 指定 side-set 占用 Delay/side-set 中的一位 4; 这里的 opt 表示 side-set 可选, 这会在 Delay/side-set 中占用一位作为 enable 5; 如果不写 opt, 启用 side-set 之后默认每条命令都必须有 side, 否则不过编译 6.side_set 1 opt 7 8; 这里的周期数变少了, 但是不需要那么高的频率, 也不会影响其他代码 9.define public T0H 1 10.define public T1H 2 11.define public TL 2 12 13.wrap_target 14 pull block 15 set x, 23 16 17loop_begin: 18 ; 逻辑限制, 拉低放在前面了, 和最后的 jmp 加起来持续 TL 19 out y, 1 side 0 [TL - 2] 20 jmp !y loop_end side 1 [T0H - 1] 21 nop side 1 [T1H - T0H - 1] 22 23loop_end: 24 jmp x-- loop_begin side 0 25 26.wrap
同时, 初始化时原本指定 set 引脚的部分改为指定 side set 引脚:
1- sm_config_set_set_pins(&c, pin, 1); 2+ sm_config_set_sideset_pins(&c, pin);
-
初始化自动获取没有使用的 state machine
1- uint sm = 0; // 选择 state machine 2- PIO pio = pio0; // 创建 PIO 实例 3- uint offset = pio_add_program(pio, &ws2812_program); 4+ uint sm; 5+ PIO pio; 6+ uint offset; 7+ bool ok = pio_claim_free_sm_and_add_program(&ws2812_program, &pio, &sm, &offset); 8+ hard_assert(ok);
-
换个好看点的颜色, 补充如下代码
1+#include <math.h> 2+ 3+static inline uint32_t color(double elapsed) { 4+ return 8 * (1 + cos(elapsed)); 5+} 6+ 7+void update_color(PIO pio, uint sm, uint32_t r, uint32_t g, uint32_t b) { 8+ uint32_t c = g << 16 | r << 8 | b; 9+ // printf("color: (%d, %d, %d)\n", r, g, b); 10+ pio_sm_put_blocking(pio, sm, c << 8); 11+} 12+ 13 14 int main() 15 { 16 // ... 17 18- pio_sm_put_blocking(pio, sm, 0xf0 << 8); 19 20 while (true) { 21- absolute_time_t t = get_absolute_time(); 22- uint32_t ms = to_ms_since_boot(t); 23- printf("Time since boot: %.3f s\n", ms / 1000.0); 24- sleep_ms(1000); 25+ double t = to_us_since_boot(get_absolute_time()) / 1e6; 26+ uint32_t r = color(t); 27+ uint32_t g = color(t + 2); 28+ uint32_t b = color(t + 4); 29+ update_color(pio, sm, r, g, b); 30+ sleep_ms(1000 / 24); 31 } 32 }
最后效果