如果你开启了广告屏蔽,请将博客园加入白名单,帮助博客园渡过难关,谢谢!
在21年做物理实验和23年客串电赛之后,我带着STM32重回电子DIY界。这次的项目是一个电池供电的补光灯,由于用途更偏向艺术创作而非严肃照明,选用了WS2812 RGB灯带;控制灯带的参数需要呈现给用户,通过LCD屏的方式;还有些其他的外设,包括输入等,不是重点。RGB灯带和LCD屏都通过SPI总线与STM32G431CBU6单片机连接。
WS2812使用800 kbps的单线归零码控制,它不是单片机具有的任何一种硬件总线。在800 kbps的速率下,直接用GPIO驱动显然只能是阻塞的。熟悉WS2812协议的读者可能会疑惑它与SPI的关系,详见下文分解。
LCD方面,此前我用的也一直是阻塞式的传输,因为在10M的速率下传输一两个字节用中断反而不合算(DMA结束也会进中断),而阻止我将若干序列打包到一起进行一次DMA传输的,是LCD信号中的D/C
,其高低电平代表数据与指令,往往是一个指令字节后跟两个数据字节,诸如此类。如果能用某种机制自动控制D/C
线,那么就很容易把指令、数据打包用DMA发送了。
理论与实践均表明,这两个外设的阻塞时间都达到了毫秒的量级,因此有必要研究非阻塞的、占用CPU时间少的控制方法。我们定义标题中的“全自动”为:在启动一次传输后,无需CPU任何干预,单片机内部其他组件能自动完成这次传输,结束后产生中断,并且中断引入的额外指令(push、pop寄存器等)的执行时间远小于实际传输的时间,从而CPU只有很小一部分时间在执行ISR。为了达到这一比例,这种传输的粒度对于LCD而言,至少要达到一次传输完成一个字符(5*7像素)的刷新,能做到字符串当然更好;对于RGB而言,没有别的选择,一次传输更新所有灯的亮度。
我们假设读者掌握基本的电子技术,具有对定时器和SPI总线的认识,且使用过中断与DMA。虽然达到这一程度的读者很大概率已经使用过本文关注的两种器件,但是为了文章的完整性,我们仍会提供对器件的基本介绍。
接单片机用的LCD,一般有8080并口、三线SPI和四线SPI等连接方式。我们不考虑读取,因为很多应用场景下从来不会从LCD读取。向LCD写入,无论是哪种接口,都需要指定当前写的字或字节是数据(data)还是指令(command),通过D/C
信号或编码在串行数据中的D/C
位。
并口一般接在单片机类似于FSMC的控制器上,并映射到32位寻址空间中,而后可以像变量一样进行读写:
#define ST7735S_CMD *(volatile uint8_t*)(0x60000000)
#define ST7735S_DAT *(volatile uint8_t*)(0x60040000)
ST7735S_CMD = 0x36; // MADCTL
ST7735S_DAT = 0b11000000;
D/C
线一般接到FSMC的一根地址线上,从而该位为高的地址对应数据,为低的对应指令。
三线SPI下,一次传输的长度为9 bit,其中首位为D/C
位,随后从MSB到LSB。通常单片机都是支持9位格式的,无论是标准的SPI还是USART组件的SPI模式。
四线SPI下,SPI部分非常标准,但是D/C
信号是标准SPI以外的,一般是用GPIO来控制。D/C
信号无需一直保持所需的电平,它跟LSB一起被采样。
LCD的控制并不是无脑地全屏填数据。在初始化完成以后,基本上只会用到3个指令:
CASET
(0x2A),设置列范围RASET
(0x2B),设置行范围
RAMWR
(0x2C),往所设定范围内填充像素(按行或按列,由MADCTL
事先设定),一个像素两个字节
例:向(16, 32)像素写入白色(0xFFFF),传输序列为
D/C |
数据 | 释义 |
---|---|---|
L | 0x2A | CASET |
H | 0x00, 0x10, 0x00, 0x10 | x=16~16 |
L | 0x2B | RASET |
H | 0x00, 0x20, 0x00, 0x20 | y=32~32 |
L | 0x2C | RAMWR |
H | 0xFF, 0xFF | 白色 |
由此可见,单独填充一个像素需要13字节,效率很低(顺便吐槽,Adafruit的LCD库画字符真的就是一个像素一个像素画的)。要提高效率,可以在本地渲染好一片区域,然后集中发送过去,这样可以降低指令的占比。
读者自然会问,这么说来,为什么不在本地把全屏都渲染好一次发送过去呢?以128*160的屏为例,在RGB565模式下,需要40 KB显存,而我用的G431一共只有32 KB内存。另一方面,128*160的分辨率对于1.8寸的屏来说像素密度已经偏低了,而屏幕更小的话又看不见了,唉……总之分块刷新是必须的。
在三种连接方式中,最容易做非阻塞的是三线SPI——只需把D/C
位打包进指令或数据即可。但是这样做的代价是DMA缓冲区大小翻倍,让本不富裕的RAM雪上加霜。
并口的速度可以达到10M量级,比两种SPI快很多,但比170M的CPU慢很多。做阻塞式传输一般都是可以接受的,至少比阻塞式SPI更能接受。但是话说回来,由于指令和数据是两个不同的地址,并口也很难做非阻塞式的传输。
四线SPI的空间利用率和并口一样都是100%,而权衡的难点已经在前言中提到了——阻塞式有一点点慢,非阻塞式粒度太小。接下来我们讨论全自动接管D/C
线的方法,从而可以增大四线SPI非阻塞式传输的粒度。
以前用8位机的时候,一个SPI、一个UART,不够用;后来用了32位机,两个SPI、两个UART,还是不够用;从来不会不够用的是动辄十几个的定时器。不仅如此,随手抓个引脚,它应该有一半的概率连接到了某个定时器的某个通道(未经验证,瞎猜的)。刚好,我用的核心板上的LCD的D/C
信号就可以接到定时器上。
定时器工作在一种类似于PFM的模式中。初始时CNT=1
,ARR
设置到一定值,CCRx=0
。定时器启动后,CNT
慢慢累加到ARR
,然后变成0
,此时CNT==CCRx
,产生compare match:
这使Channel x的输出翻转;
产生DMA请求,从内存中读一个值写入ARR
。
这个功能非常基本,但凡是个有输出通道的定时器都能轻松胜任,不需要更高级的特性。
经试验,SPI的SCK
频率是严格的CPU整数分频,不会在字节间插入额外的延迟(归功于FIFO;仅适用于STM32G4系列,其他系列可能具有不同的SPI IP)。因此,要被写入ARR
的值为N*8-1
,N
为连续的指令或数据的字节数。
实验平台为STM32G431核心板(某宝有售),预留了0.96或1.14寸LCD的排线焊盘,买来对应的LCD焊上即可。单片机与LCD的连接如下:
LCD引脚 | 单片机引脚 | Alternate function |
---|---|---|
SCL |
PB13 |
SPI2_SCK |
SDA |
PB15 |
SPI2_MOSI |
RS (D/C) |
PB10 |
TIM2_CH3 |
CS |
PB12 |
N/A |
Cube配置:
SPI2 |
TIM2 |
---|---|
SPI方面,我们需要一个传输完成的中断来拉高CS
信号。SPI本身只提供了FIFO有空位的中断,我们转换一下思路,SPI接收一定是完成以后才会触发中断的。因此我们再开一路DMA给SPI_RX
,以它的中断为传输完成的标志。当然,接收的数据没有意义,那就设置一个字节的垃圾桶,DMA接收的内容往里面写,内存地址不增长。
定时器方面,由于时间不紧张以及为了省事更省心,我们关闭了ARR
的preload。
一些注意事项:
HAL配置很耗时,会破坏SPI与定时器同步的初始条件。解决方案有:a) 自己调用LL;b) 把HAL代码拷出来,去掉最后启动SPI或定时器的一行,两个分别配置完后一起启动,只相差一行代码的时间。
一次传输结束后,D/C
一般为高,而下次传输一般是以D/C
为低开始的。要手动指定这一信号的状态,可以调用LL_TIM_OC_SetMode(htim2.Instance, LL_TIM_CHANNEL_CH3, LL_TIM_OCMODE_FORCED_INACTIVE)
,完事后记得改回去。
注意设置初始状态,以及定时器的第一个字节是不走DMA的,而是直接设置给ARR
,并且加上一个偏置以补偿初始时CNT=1
以及SPI启动的延迟,经试验htim->Instance->ARR = *pData + 3
比较合适。
为了方便用逻辑分析仪查看结果,系统时钟设置为16 MHz。SPI时钟频率为4 MHz。
传输起始:
中间切换指令/数据类型:
在最后一段数据较短时,D/C
信号会产生一次额外的翻转,不过是在传输结束之后,没有实际影响。
由于涉及两个组件的协同工作,并且它们都是由CPU启动的,就必然会有它们之间同步问题的担忧,这包括初始的相位差和两者频率差异导致的累积相位差。在SPI FIFO始终不空的前提下,SPI的时序精确可知;在定时器DMA延迟足够低的前提下,定时器的时序也精确可知,可以做到两者频率严格一致。至于初始相位差,一定程度上是试出来的,并且还依赖于各个分频系数,甚至编译器优化,当然也与选用的单片机系列有关。好在这一延迟只需精确到SPI传输一个字节的时间,算是相对宽松的要求。
实际应用中有时SPI还从USART的SPI模式中来,需要注意USART的分频比SPI组件的复杂很多,还需要考虑起始、终止位等等。
前面的实验都是在4分频下进行的,即SPI时钟频率和定时器频率均为CPU频率的四分之一。经尝试,更低的频率都能正常工作,但是若将频率提升到二分频,定时器不能按照预期工作。观察波形并分析原因,应该是更新ARR
时CNT
已经超过ARR
导致的,这也是定时器需要引入preload的原因,前面我们图省事把它关掉了。一般来说,32位机的SPI不大会工作在这么高的频率;如果需要,必须开启ARR
preload,并额外考虑初始和终止处理。
单实现一个非阻塞式的写LCD操作并没有可用的实际意义——也许你要刷新的是全屏,但前面已经提到内存不够放下全屏内容;又或许你先提交一串指令,然后又要更新另一块区域,而要开始这段传输还得等前一段完成,又成阻塞式的了。因此下一步要做的是实现一个队列,在一次DMA结束的中断里启动下一次DMA,以及可选地一个图形管理器,从而可以在队列中存储绘图指令而非渲染后的画面,以节省内存开销。我的一篇早期文章可能提供了相关的代码工具。
nRF52840的SPI外设集成了D/C
信号,但是它只能指定前多少字节是指令,此后全为数据,不能在一次DMA中重新切换回指令。
一些为低功耗优化的单片机,如STM32U5,具有带链表功能的DMA。这提供了非常大的自由:你可以把每一段指令或数据放在一次DMA传输中,中间插入对定时器或GPIO的DMA以改变D/C
的电平,最后把这些配置整合成一个链表,DMA控制器会自动遍历链表中指定的操作——无需CPU干预。
引用我之前的文章:
WS2812B的信号是单线的,一方面这简化了灯带的设计,对级联也比较友好,但另一方面这种信号不是任何一种常见的总线,也不能由常见总线信号通过简单变换得到,这带来了一些困难。
每一位都是先高电平后低电平,
0
和1
的差别在于高低电平的时间不同,0
的高电平时间比较短。允许的时间范围都是比较宽的。通常每一位都是等长的,那么一位的时间范围为1.16 μs到1.38 μs。每个灯有4个引脚:
VCC
、GND
、DIN
、DO
。DO
上的信号是DIN
信号除了前24个bit以外的部分,这24个bit以绿红蓝、MSB优先的顺序锁存进WS2812B。前一个灯的DO
接后一个的DIN
,如此级联。没有信号时数据线保持低电平,当低电平时间超过280 μs时就会RESET,锁存的数据更新到亮度上。所有级联的灯在几乎同一时刻更新。
友情提醒:各个渠道的灯珠内置的WS2812B不尽相同,有的支持TTL电平而有的必须VH > 0.7 VDD
,有的时序宽松有的严格,而且你根本找不到所购买的灯珠对应的规格,所以量产前请务必打样测试。
GPIO加延时的方案就不讲了。我在网上找到的硬件实现有:
SPI的MOSI
信号
前三种方案都来自于这篇文章。
SPI的MOSI
信号可以直接驱动WS2812。若用3位SPI编码一位WS2812信号,0
为一个高加两个低,1
为两个高加一个低;若用4位SPI编码一位WS2812信号,0
为一个高加三个低,1
为两个高加两个低。
空间利用率1/3或1/4。3位方案下,运算会相对复杂一些;而4位的方案空间利用率稍低。
定时器PWM
这是GitHub上最常见的方案,毕竟如前所述,大家定时器都多到用不完。
定时器工作在PWM模式下,频率固定为800 kHz,占空比为CCRx / (ARR+1)
。每个计数周期用DMA传送进新的CCRx
,从而可以逐周期地改变占空比,用以表示WS2812的0
和1
。
一般来说,CCRx
用低8位即可,此时空间利用率1/8,即一位WS2812信号需要一个字节内存。
定时器触发的GPIO DMA
这是一个略有难度的实现。开一个定时器的两个通道,开三个DMA通道,请求分别为计数器溢出和两个通道的匹配,目标地址为GPIO的寄存器。在溢出时,所有IO变为高电平;需要写0
的位在先匹配的时候置低,需要写1
的在另一个匹配置低。
若DMA宽度为16位,则空间利用率为1/32,因为每一个WS2812的bit需要两个16位GPIO写入。
SPI与定时器联合
我们让定时器产生两倍于
SCK
频率的方波WO2
,上升沿对齐;MOSI
设置为上升沿更新,从SCK
上升沿到下一个上升沿为一个bit。在这一bit中,高电平占前1/4为WS2812B的0
,1/2为1
。
LUT
寄存器的8位分别存放IN[2:0]
的8种状态对应的输出。根据前面的时序图,在011
、101
和111
三种情况下输出为1
,LUT
值为0xA8
。
空间利用率100%,但是在ATtiny3217上只能单字节传输(人家连DMA都没有)。
我们发现前3个方案的空间利用率都比较低,在有100个灯有效数据2.4 Kb下,这三个方案需要的DMA缓冲区都比较大(其实并不是不能承受,但我们要面向未来)。GitHub上一般的方案是开一个长为两个灯的环形DMA,在传输完成一半时更新前一半,完成一轮时更新后一半,但这违背了我们“全自动”的目标。
方案4的空间利用率非常理想,但是因为涉及到和定时器的协同,就像前半部分LCD一样,容易出时序问题。尽管定时器波形是由CPU时钟得到,SPI也是,但我们更希望定时器波形能直接从SCK
获得。
很自然的想法是把SCK
倍频一下,但是实现起来几乎没有可行性。同样很自然地,转换一下思路,我们就把SCK
当作原来的倍频信号,而原来SCK
就用现在SCK
的二分频取代——二分频是很容易获得的。
很遗憾,这种方案下空间利用率下降到了50%,但这就是本文要介绍的方案。我们将在最后讨论100%空间利用率的衍生方案。
首先对SCK
进行二分频,使用的是D触发器74LVC1G74:将反向输出Q
接到输入D
上,在时钟CP
(接SCK
)的每个上升沿锁存,即输出信号反相,每两个上升沿一个周期,即二分频。将输出Q
标记为DIV
信号。
这样一来,每一个WS2812的bit将由两个SPI的bit产生。我们规定WS2812的0
对应SPI的00
,1
对应11
。事实上第二个bit的值是无所谓的,但是我们将在后面看到这样做的好处。
然后是后面的组合逻辑。根据时序图,我们列出真值表:
MOSI |
DIV |
SCK |
RGB |
---|---|---|---|
0 | 0 | 0 | 0 |
0 | 0 | 1 | 0 |
0 | 1 | 0 | 0 |
0 | 1 | 1 | 1 |
1 | 0 | 0 | 0 |
1 | 0 | 1 | 0 |
1 | 1 | 0 | 1 |
1 | 1 | 1 | 1 |
用眼睛看也好,画卡诺图也好,反正最后化简出来的逻辑是:\(\mathrm{RGB} = (\mathrm{MOSI} + \mathrm{SCK}) \cdot \mathrm{DIV}\)。然而一个或门和一个与门并不能一起用一片常用逻辑芯片实现,我们可以把它写成\(RGB = \overline{ \overline{\mathrm{MOSI} \cdot \mathrm{DIV}} \cdot \overline{\mathrm{SCK} \cdot \mathrm{DIV}}}\),这样就能用一片74HC00(四2输入与非门)实现了。
一个小问题是D触发器的初始状态是不确定的,为了使DIV
信号的闲时电平为低,我们把D触发器的RD
信号接到单片机一个GPIO,该引脚电平为低时,D触发器的输出Q
保持低电平。原则上上电后清零一下就可以,保险起见也可以每次传输前清零一次。
实验平台仍然是STM32G431核心板。
逻辑电路需要额外搭建。需要的逻辑芯片我手头都有,焊到贴片转直插的板上再在面包板上接接线就可以实验了,但是我觉得这样做不优雅。
终于到了让我夹带私货的时候了。21年的时候我设计了一批贴片万用板,不仅集成了SOP、SOT-23和TSSOP等多种封装(共享焊盘),还引出了一些横向竖向的互连线路,用户只需在合适的位置焊接上电阻、电容,就能实现中等复杂度的电路。对于运放、比较器等特殊的IC,也有专门的PCB可以同时支持同相、反相放大、加减运算、一阶、二阶有源滤波器等等。此外,不仅板间可以在二维地通过排针排母互连,甚至垂直方向上都可以连接。
设计很花哨,但并不是没有实用价值。下面图片展示的是23年电赛期间我们制作的模块,由3层组成:底层是用正电压产生负电压的电源转换模块,中间是模拟信号加法电路,上面是SMA信号输入。这个实现非常优雅,至少我自认为如此。
言归正传,我们延续这种优雅的设计,在一块最大支持TSSOP-28的万用板上搭建所需的逻辑。
焊接前 | 焊接后 | 细节 |
---|---|---|
Cube配置:
时钟选择25 MHz的HSE,最终信号速率781.25 kHz,接近800 kHz。
SPI宽度为16位,对应8位实际数据,其中的对应关系可以一位一位算,更快的方式是查表:
static const uint16_t rgb_table[256] =
{
0x0000, 0x0003, 0x000C, 0x000F, 0x0030, 0x0033, 0x003C, 0x003F,
0x00C0, 0x00C3, 0x00CC, 0x00CF, 0x00F0, 0x00F3, 0x00FC, 0x00FF,
0x0300, 0x0303, 0x030C, 0x030F, 0x0330, 0x0333, 0x033C, 0x033F,
0x03C0, 0x03C3, 0x03CC, 0x03CF, 0x03F0, 0x03F3, 0x03FC, 0x03FF,
0x0C00, 0x0C03, 0x0C0C, 0x0C0F, 0x0C30, 0x0C33, 0x0C3C, 0x0C3F,
0x0CC0, 0x0CC3, 0x0CCC, 0x0CCF, 0x0CF0, 0x0CF3, 0x0CFC, 0x0CFF,
0x0F00, 0x0F03, 0x0F0C, 0x0F0F, 0x0F30, 0x0F33, 0x0F3C, 0x0F3F,
0x0FC0, 0x0FC3, 0x0FCC, 0x0FCF, 0x0FF0, 0x0FF3, 0x0FFC, 0x0FFF,
0x3000, 0x3003, 0x300C, 0x300F, 0x3030, 0x3033, 0x303C, 0x303F,
0x30C0, 0x30C3, 0x30CC, 0x30CF, 0x30F0, 0x30F3, 0x30FC, 0x30FF,
0x3300, 0x3303, 0x330C, 0x330F, 0x3330, 0x3333, 0x333C, 0x333F,
0x33C0, 0x33C3, 0x33CC, 0x33CF, 0x33F0, 0x33F3, 0x33FC, 0x33FF,
0x3C00, 0x3C03, 0x3C0C, 0x3C0F, 0x3C30, 0x3C33, 0x3C3C, 0x3C3F,
0x3CC0, 0x3CC3, 0x3CCC, 0x3CCF, 0x3CF0, 0x3CF3, 0x3CFC, 0x3CFF,
0x3F00, 0x3F03, 0x3F0C, 0x3F0F, 0x3F30, 0x3F33, 0x3F3C, 0x3F3F,
0x3FC0, 0x3FC3, 0x3FCC, 0x3FCF, 0x3FF0, 0x3FF3, 0x3FFC, 0x3FFF,
0xC000, 0xC003, 0xC00C, 0xC00F, 0xC030, 0xC033, 0xC03C, 0xC03F,
0xC0C0, 0xC0C3, 0xC0CC, 0xC0CF, 0xC0F0, 0xC0F3, 0xC0FC, 0xC0FF,
0xC300, 0xC303, 0xC30C, 0xC30F, 0xC330, 0xC333, 0xC33C, 0xC33F,
0xC3C0, 0xC3C3, 0xC3CC, 0xC3CF, 0xC3F0, 0xC3F3, 0xC3FC, 0xC3FF,
0xCC00, 0xCC03, 0xCC0C, 0xCC0F, 0xCC30, 0xCC33, 0xCC3C, 0xCC3F,
0xCCC0, 0xCCC3, 0xCCCC, 0xCCCF, 0xCCF0, 0xCCF3, 0xCCFC, 0xCCFF,
0xCF00, 0xCF03, 0xCF0C, 0xCF0F, 0xCF30, 0xCF33, 0xCF3C, 0xCF3F,
0xCFC0, 0xCFC3, 0xCFCC, 0xCFCF, 0xCFF0, 0xCFF3, 0xCFFC, 0xCFFF,
0xF000, 0xF003, 0xF00C, 0xF00F, 0xF030, 0xF033, 0xF03C, 0xF03F,
0xF0C0, 0xF0C3, 0xF0CC, 0xF0CF, 0xF0F0, 0xF0F3, 0xF0FC, 0xF0FF,
0xF300, 0xF303, 0xF30C, 0xF30F, 0xF330, 0xF333, 0xF33C, 0xF33F,
0xF3C0, 0xF3C3, 0xF3CC, 0xF3CF, 0xF3F0, 0xF3F3, 0xF3FC, 0xF3FF,
0xFC00, 0xFC03, 0xFC0C, 0xFC0F, 0xFC30, 0xFC33, 0xFC3C, 0xFC3F,
0xFCC0, 0xFCC3, 0xFCCC, 0xFCCF, 0xFCF0, 0xFCF3, 0xFCFC, 0xFCFF,
0xFF00, 0xFF03, 0xFF0C, 0xFF0F, 0xFF30, 0xFF33, 0xFF3C, 0xFF3F,
0xFFC0, 0xFFC3, 0xFFCC, 0xFFCF, 0xFFF0, 0xFFF3, 0xFFFC, 0xFFFF,
};
RGB灯按照预期点亮。
细心的读者可能会发现,上面的时序图中并没有展示原理图中的RGB_RES
。其实我根本没有接这根线,因此上电后DIV
的状态也是不确定的。前面的时序图是DIV
上电后为低电平的结果,但是如果它上电后是高电平,RGB灯也一样工作,看下面的时序图很容易明白这一点。
事实上,输出信号只是滞后了一个SCK
周期的时间(若SPI的第二个bit不是前一个的复制,则不会有此结果,这就是前面提到的好处)。唯一不同的是,DIV
闲时为高电平,而SPI传输结束后MOSI
保持最后一个bit的电平,若为高,则输出也一直保持高电平,将导致WS2812收不到RESET
信号,从而无法更新亮度。为了避免这种现象,只需保证最后一个字节的LSB为0
——但这最后一个字节不一定要是实际亮度的最后一个字节,它可以是附加的字节,从而对实际亮度没有任何影响。
如果你执着于这个RGB_RES
又不想占用GPIO,这里还有两个方案,一个是用电阻电容实现开机清零,另一个如下图(不解释了,自己理解吧):
SPI的分频只有2的幂次,即使考虑了时钟树上前级的分频,这会导致若要CPU运行在最高频率,最终产生的信号频率会偏离800 kbps较远(实际上在一个比较宽的范围内都能工作)。使用USART的SPI模式有助于改善频率问题,但是由于起始位终止位的存在,USART可能会破坏最终产生的信号,我还没有验证过。
以上我们讨论和实现的都是单串WS2812控制信号。如果因为刷新率限制或者为了方便布线需要多路控制信号,这些方案是否能扩展?
基于SPIMOSI
的方案1,自然可以用更多的SPI外设扩展,之间相互独立;或者用QSPI扩展到4路,但是只能同时使用。
基于定时器PWM的方案2,很容易扩展到4路,只需同一定时器的4个通道,而且可以自由配置启动哪些通道(如果启用连续的通道,可以考虑DMA burst);当然也可以加入其他定时器,配置就更自由了。
基于GPIO DMA的方案3,能直接扩展到16路(尽管实际上很少能同时使用一个GPIO port的所有pin),如果真的需要这么多路,个人倾向于此方案,毕竟现在STM32G030的价格真的比你去菜市场买颗白菜还便宜……
本文提出的方案:
可以用更多SPI扩展,通道数即SPI实例数,外部电路数量同样成正比;
要扩展到2路,可以将SPI换为I2S,外加一些逻辑让WS
信号选择当前输出哪一路——两路不能同时输出,这只能优化布线而不能提高吞吐量;
要扩展到2路,还可以用SAI(Serial Audio Interface),把它配置为共享时钟的两个SPI,这是能提高吞吐量的;
要扩展到4路,可以将SPI换为QSPI(注意STM32G431并没有QSPI),D锁存器只需一个,但与非门共需要9个。
回顾一下前面提到的方案4,ATtiny3217不能实现连续的多字节WS2812信号的根本问题不在于它没有DMA,而是SPI字节之间有额外的CPU周期。在前一板块我们已经验证过,STM32的SPI时钟是非常连续的,那么这套方案能不能用在STM32上呢(别忘了它的空间效率达到了100%!)?难点在于SPI和定时器之间的同步需要精确到单CPU周期,但是我相信借助STM32G系列引入的DMA请求同步系统(在DMAMUX内),这个方案也是有希望实现的。
前面提到的ATtiny3217具有内部的可编程逻辑,但这并非8/16位机的专利,目前已经能见到一些32位机搭载了相似的功能,如Silicon Labs的EFM32以及射频SoC EFR32等,具有12异步通道的PRS(Peripheral Reflex System),每个都能实现2输入组合逻辑,通道之间有互连,可以组合成简单的时序逻辑。
当我给别人讲起本文所描述的SPI加定时器或者SPI加外部逻辑这种奇怪的搭配,我常听到两种声音。
第一种是“还能这样用???”的确,像定时器PWM的频率和占空比用DMA改变这种算是相对深入的用法,而总线信号加逻辑运算会要求你回顾尘封的数电知识,都是听起来觉得可能有道理但是要自己想到还是需要很多基础和经验的。最近培训班席卷嵌入式,我想读者们多少有所耳闻。我敢打包票培训班出来的全是这一类。
第二种是“搞这些干什么,上FPGA得了!”我认可这种观点,因为用FPGA实现在除成本以外的任何方面都是更优的,我也为我到现在都只会入门级的FPGA编程而对过去的自己怀恨在心。然而,用FPGA一定比用MCU高级吗?也许是,也许不是。为了省点芯片的成本去花很长时间构思一个精巧的电路值得吗?有时成本更重要,有时你的时间更宝贵。无论如何,构思一个方案,查点相关资料,确认原创性,优雅地实现——我很快乐,这就够了。