news 2026/10/5 2:03:44

STM32驱动WS2812呼吸灯:PWM+DMA方案实现顺滑渐变

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32驱动WS2812呼吸灯:PWM+DMA方案实现顺滑渐变

把呼吸灯做到有“呼吸感”,难度其实比很多人想象的高不少。我第一次用STM32裸驱WS2812灯带时,先用了GPIO翻转加短延时的方式写了个测试版本,单颗灯珠一切正常,接上30颗灯珠跑呼吸渐变,画面直接变成肉眼可见的闪烁和跳变,那种感觉就像灯带“接触不良”。后来把发送逻辑整个丢给PWM+DMA硬件链路,效果才真正顺滑起来。这篇文章就把这套方案的原理、配置、完整代码和排错经验完整记录下来,给想用STM32做WS2812渐变效果(尤其是呼吸灯)的朋友一份可直接复现的参考。

1. 呼吸灯“不呼吸”的病根:WS2812时序与CPU过载

1.1 WS2812的bit时序到底有多苛刻

WS2812这类单线协议灯珠,本质上是用一个数据引脚,以严格的时序来区分逻辑0和逻辑1。每一bit的周期固定为1.25us,0码的高电平约350ns,1码的高电平约800ns,其余时间为低电平。也就是说,1.25us这个时间窗口内,高电平多宽直接决定了这个bit是0还是1。

  • 0码:周期1.25us,高电平350ns,低电平900ns
  • 1码:周期1.25us,高电平800ns,低电平450ns
  • 帧间RESET:低电平持续50us以上

这个时序规则来自灯珠内部芯片的采样逻辑:它在每个bit周期内采样高电平的宽度,如果超过一定阈值就判定为1,否则判定为0。问题在于,1.25us的窗口非常短暂,任何一个额外中断、一次函数调用开销过大,都可能让高电平宽度失准。失准的后果也很直接:灯珠不亮、颜色乱跳、相邻灯珠颜色不一致。

很多人第一次驱动WS2812时,会用HAL_GPIO_WritePin配合delay_us来实现这个时序。单颗灯珠测试时看不出问题,因为系统没有其他任务干扰,但是当灯珠数量增加,或者动画计算复杂起来之后,CPU需要一边计算渐变颜色,一边紧盯着每一位的时序,任何一处延迟都会让整个数据帧废掉。

1.2 为什么GPIO翻转方案会翻车

GPIO翻转方案的典型实现思路是:针对每一位,先拉高引脚,延时一段,再拉低,延时补足剩余周期。例如0码就高电平延时350ns,低电平延时900ns;1码就高电平延时800ns,低电平延时450ns。

这套逻辑在理论层面没有错,问题出在“延时”本身。在STM32上,简单用for循环做短延时时,代码执行周期受编译器优化等级、Flash等待周期、中断抢占等因素影响,实际延时并不稳定。尤其是在跑呼吸灯这类需要持续更新颜色数据的动画时,CPU不仅要发数据,还要做sin计算、颜色分量缩放、多灯珠数据拼接,稍微一个中断插进来,紧跟在中断后面的那个bit就可能出错。

另一个更隐蔽的问题是,30颗灯珠就是30×24=720个bit,按每bit1.25us算,一帧数据发送大约需要0.9ms。这个过程中如果来了SysTick中断、串口中断,哪怕只打断几个微妙,都会让灯带出现肉眼可见的噪点或者整条变暗。所以GPIO翻转方案做静态灯效还能勉强跑,做呼吸渐变基本就是拼运气。

1.3 PWM+DMA的组合为什么是正解

PWM+DMA的核心思路,是把“发送每一位”这件事从CPU手里完全接管过来。定时器以800kHz的频率输出PWM,这正好对应1.25us的bit周期。在定时器每个周期更新时,DMA自动把预先存放在缓冲区里的CCR值装入比较寄存器,从而改变当前周期的高电平宽度。

  • 如果缓冲区里某个元素是CCR_FOR_0(对应0码的高电平宽度),这个bit周期就输出0码波形
  • 如果元素是CCR_FOR_1(对应1码的高电平宽度),这个bit周期就输出1码波形

CPU要做的事情只有一个:在DMA发送每一帧之前,把需要显示的颜色数据转换成一组CCR值填进缓冲区。之后整个发送过程完全由定时器和DMA硬件协作完成,CPU可以腾出手来计算下一帧的呼吸亮度值。

我当时实测下来的感受是,同样的30颗灯珠,GPIO翻转方案跑的呼吸灯效果像抽风,换成PWM+DMA之后,CPU负载降到几乎为零,灯珠的渐变更细腻、更连贯。而且这套方案还有一个额外的好处:多颗灯珠时,只要缓冲区够大,DMA会自动连续发完一整帧数据,不会在发到一半时被打断。

2. 工程配置:从CubeMX到定时器DMA链路

2.1 硬件准备与引脚选择

这套方案需要的硬件非常基础:一块STM32F103C8T6最小系统板、一根WS2812灯带或灯板、用来给灯带供电的5V电源,以及若干杜邦线。如果想稳妥一点,再准备一个74AHCT1G125或者74HCT245做数据线电平转换,不过现在市面上很多灯带内部带了逻辑整形电路,STM32的3.3V高电平直驱也能工作,建议到手先做单灯测试。

引脚选择上,我推荐PA8,因为它复用为TIM1_CH1,而TIM1在STM32F103上挂在APB2总线,时钟可以跑到72MHz,正好用于生成800kHz的PWM。如果你用的是其他芯片型号,只要找到对应的定时器通道PWM引脚即可,原理完全一样。

接线方式如下:

组件引脚连接目标
STM32 PA8TIM1_CH1灯带数据线(Din)
STM32 GND系统地5V电源负极、灯带GND
5V电源正极灯带VCC给灯带独立供电

这里特别提醒一件事:不要把灯带直接接到开发板的3.3V或5V引脚取电。WS2812单颗全白电流约60mA,30颗全白就是1.8A,开发板上的稳压电路和引脚根本扛不住。一旦电压跌落,灯珠的逻辑电平也会跟着异常,颜色会出现各种莫名其妙的问题。

2.2 CubeMX里的PWM和DMA参数怎么填

CubeMX配置看起来简单,但有几个参数填错会导致整个链路完全跑不通。先把我的最终配置列出来:

配置项参数说明
RCCHSE,系统时钟72MHzTIM1挂在APB2,时钟可达72MHz
TIM1 Prescaler0不分频,计数时钟72MHz
TIM1 Counter Period89每周期90个计数点,72000000 / 90 = 800kHz
TIM1 Pulse0初始占空比,启动后由DMA覆写
PWM ModePWM Generation CH1输出通道1
Auto-reload preloadEnable避免更新CCR时出现撕裂
DMA RequestTIM1_CH1在DMA Settings里添加
DMA DirectionMemoryToPeripheral从内存到定时器比较寄存器
DMA ModeNormal每帧传输完自然停止,便于插帧间RESET
DMA Data WidthHalf Word / Half Word16位宽度,对应uint16_t缓冲区
DMA PriorityVery High防止其他DMA请求抢占
Memory IncrementEnable每传一个元素,源地址加1
Peripheral IncrementDisable目标地址固定为CCR1

有人会问,为什么Counter Period是89而不是其他值?因为90 × 1.25us = 112.5us?不对,重新算一下:72MHz时钟下,每1.25us是90个计数周期,所以ARR = 90 - 1 = 89。这样PWM频率就是72000000 / 90 = 800000Hz,即每个bit占1.25us,和WS2812的位周期完全对应。

配置完CubeMX之后,记得在NVIC设置里使能DMA的中断。很多人会忽略这一步,结果数据传输完成了,回调函数却不执行,灯带显示一次后就再也不更新。

2.3 DMA缓冲区的宽度陷阱

DMA传输的数据宽度有三种选择:Byte、Half Word、Word。这里建议使用Half Word,也就是16位,和定时器CCR寄存器低16位匹配。缓冲区类型定义成uint16_t数组,每个元素存放一个CCR值。

这里有一个容易踩的坑:如果缓冲区定义成uint8_t数组,然后强行用指针转成uint16_t地址传给DMA,很容易因为内存对齐问题出现hardfault或者DMA传输数据错位。正确做法就是老老实实定义成uint16_t数组,让编译器保证自然对齐。

缓冲区大小是这样计算的:每颗灯珠24bit,N颗灯珠需要N×24个uint16_t元素。比如16颗灯珠就是384个元素,占用768字节SRAM;60颗灯珠是1440个元素,占用2880字节。对于STM32F103C8T6的20KB SRAM来说,即使144颗灯珠也只占约7KB,完全扛得住。

3. 代码实现:颜色映射、渐变算法与帧切换

3.1 把RGB“翻译”成CCR值序列

WS2812的24bit数据顺序是Green、Red、Blue,不是常规的RGB顺序。如果直接按RGB顺序发送,你会看到红蓝互换,或者颜色完全不对。这是新手最容易踩的坑,我就在这上面浪费过一晚上。

核心映射逻辑是把GRB三个字节拼成一个32位整数,然后从最高位开始逐位判断。这一位是1,就在缓冲区对应位置填入CCR_FOR_1;这一位是0,就填入CCR_FOR_0。因为DMA发送时,定时器每个周期从缓冲区取一个值,所以缓冲区里的顺序就是发送顺序。

/* ws2812_pwm.h */ #ifndef __WS2812_PWM_H #define __WS2812_PWM_H #include "main.h" #define LED_COUNT 16 #define CCR_FOR_0 25 #define CCR_FOR_1 57 #define RESET_DELAY_US 60 extern volatile uint16_t ws2812_dma_buf[LED_COUNT * 24]; void ws2812_init(TIM_HandleTypeDef *htim); void ws2812_set_color(uint16_t index, uint8_t r, uint8_t g, uint8_t b); void ws2812_fill_color(uint8_t r, uint8_t g, uint8_t b); void ws2812_update(void); #endif

3.2 基于正弦的呼吸亮度算法与参数选择

呼吸灯的精髓在于亮度变化要平滑,不能像跑马灯那样突亮突灭。最简单的自然呼吸算法是用正弦函数生成一个0到1之间的亮度系数:

brightness = (sin(phase) + 1) / 2

phase随时间从0增长到2π再循环,这样brightness会从0平滑上升到1,再平滑回落到0。如果想让呼吸效果看起来更自然,可以给亮度加一个底值,比如:

brightness = 0.05 + 0.95 × ((sin(phase) + 1) / 2)

这个底值的作用是让灯珠在最低亮度时也不会完全熄灭,更贴合现实中呼吸灯那种“余辉”的质感。

呼吸周期我习惯设成2000ms,也就是说phase每2000ms走完一个完整正弦周期。人对这个频率的呼吸感最舒适。如果周期太短,灯光会显得急促;周期太长,又会让观者产生等待感。

3.3 无缝帧切换:DMA中断与RESET时序

WS2812要求两帧数据之间有一个至少50us的低电平RESET。如果不处理这个间隔,灯带会把连续两帧数据当成一帧处理,后面的灯珠颜色会乱掉。

由于PWM输出一直存在,即使缓冲区里的CCR值已经发完,定时器仍然会输出最后一个值对应的波形。所以必须在每帧发送完之后,主动把数据引脚拉低,保持一段时间,再重新启动PWM和DMA。

具体流程我放在主循环中处理:

  • 启动DMA发送当前帧
  • 等待DMA传输完成中断,置tx_busy标志为0
  • 主循环检测到tx_busy为0后,停止PWM,把PA8配成普通GPIO并输出低电平
  • 延时60us,确保RESET信号满足要求
  • 把PA8恢复成复用推挽输出,重新填充缓冲区,再次启动DMA

这套流程里,关键点是DMA传输完成回调只会把tx_busy清0,不在中断里做任何耗时的操作。这样能保证中断服务程序极其短小,不会干扰其他外设。

3.4 完整核心代码

/* ws2812_pwm.c */ #include "ws2812_pwm.h" #include <string.h> #include <math.h> static TIM_HandleTypeDef *htim; static volatile uint8_t tx_busy = 0; volatile uint16_t ws2812_dma_buf[LED_COUNT * 24]; void ws2812_init(TIM_HandleTypeDef *tim) { htim = tim; memset((void *)ws2812_dma_buf, 0, sizeof(ws2812_dma_buf)); tx_busy = 0; HAL_TIM_PWM_Start_DMA(htim, TIM_CHANNEL_1); } void ws2812_set_color(uint16_t index, uint8_t r, uint8_t g, uint8_t b) { if (index >= LED_COUNT) { return; } uint32_t grb = ((uint32_t)g << 16) | ((uint32_t)r << 8) | b; uint16_t *buf = (uint16_t *)&ws2812_dma_buf[index * 24]; for (uint32_t i = 0; i < 24; i++) { if (grb & 0x800000) { buf[i] = CCR_FOR_1; } else { buf[i] = CCR_FOR_0; } grb <<= 1; } } void ws2812_fill_color(uint8_t r, uint8_t g, uint8_t b) { for (uint16_t i = 0; i < LED_COUNT; i++) { ws2812_set_color(i, r, g, b); } } void ws2812_set_breath(uint8_t r, uint8_t g, uint8_t b, float phase) { float brightness = (sinf(phase) + 1.0f) * 0.5f; brightness = 0.05f + 0.95f * brightness; uint8_t rr = (uint8_t)((float)r * brightness); uint8_t gg = (uint8_t)((float)g * brightness); uint8_t bb = (uint8_t)((float)b * brightness); ws2812_fill_color(rr, gg, bb); } void ws2812_update(void) { while (tx_busy) { // 等待上一帧DMA传输完全结束 } HAL_TIM_PWM_Stop_DMA(htim, TIM_CHANNEL_1); // 配置PA8为普通推挽输出,拉低,产生RESET信号 GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_8; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &gpio); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_RESET); delay_us(RESET_DELAY_US); // 恢复PA8为复用推挽输出,重新挂到TIM1_CH1 gpio.Mode = GPIO_MODE_AF_PP; HAL_GPIO_Init(GPIOA, &gpio); tx_busy = 1; HAL_TIM_PWM_Start_DMA(htim, TIM_CHANNEL_1); } void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { tx_busy = 0; } }
/* main.c 中的关键调用示例 */ #include "main.h" #include "ws2812_pwm.h" #include <math.h> #define PI_2 6.28318530718f void delay_us(uint32_t us) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; uint32_t start = DWT->CYCCNT; uint32_t ticks = us * 72; while ((DWT->CYCCNT - start) < ticks); } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_TIM1_Init(); ws2812_init(&htim1); uint32_t lastTick = 0; while (1) { uint32_t now = HAL_GetTick(); if (now - lastTick >= 20) { lastTick = now; float phase = (float)(now % 2000) / 2000.0f * PI_2; ws2812_set_breath(255, 160, 80, phase); ws2812_update(); } } }

4. 实测与排查:三种典型异常现象的处理

4.1 灯珠颜色完全乱掉:先查时序,再查数据格式

如果你烧录代码后,灯带完全没反应或者颜色随机乱跳,优先怀疑两个方向:CCR时序参数是否匹配,以及数据发送顺序是否正确。

CCR参数方面,我给的0码CCR=25、1码CCR=57是基于标准WS2812时序计算出来的。不同批次的灯珠对时序的敏感度会有一点差异。你可以把这两个宏定义做成可调参数,先用单颗灯珠做测试:发送纯红色,如果红色能正常显示且不串色,说明时序基本没问题。

如果颜色出现红蓝互换的情况,基本可以断定是GRB顺序写错了。WS2812的数据顺序是Green优先,不是传统的RGB。把你拼接32位数据的代码改成:

uint32_t grb = ((uint32_t)g << 16) | ((uint32_t)r << 8) | b;

这样就可以解决绝大多数颜色错乱问题。还有一个容易忽略的细节:高位在前。也就是说,每一颗灯珠的第一个bit是Green通道的最高位,判断顺序应该是0x800000开始逐位右移。

4.2 渐变过程出现明显闪动:DMA优先级与缓冲区冲突

呼吸渐变时灯光整体在动,但偶尔会出现一帧抖动甚至某个灯珠闪烁一下,这类问题多半出在DMA优先级或者缓冲区被意外改写。

DMA优先级我建议直接拉到Very High。如果系统里还有串口DMA、ADC DMA,默认的Medium优先级很可能在某一瞬间和TIM1_CH1的DMA请求竞争总线,导致定时器周期内的CCR更新晚了一个周期,占空比错乱。这个错乱肉眼看不出来每一帧,但累积起来就会表现为闪烁。

缓冲区冲突的问题则和代码结构有关。ws2812_update函数里,我通过tx_busy标志等待上一帧发送完之后才修改缓冲区。如果你在我的代码基础上又加了其他逻辑,比如在定时器中断里直接调用ws2812_set_color去改缓冲区,而这时DMA还在读缓冲区,就会产生竞争。解决办法只有一个原则:DMA传输期间,绝对不要碰缓冲区。任何颜色更新都放在DMA传输完成之后。

4.3 亮度曲线生硬不自然:人眼感知与Gamma修正

正弦计算出来的亮度理论上很平滑,但实际看起来可能觉得“暗部很快变黑,亮部又很快饱和”。这其实不是算法错了,而是因为LED的物理亮度与感知亮度并非线性关系。人眼对暗部亮度变化更敏感,对亮部变化相对迟钝。

想在观感上更接近真实的呼吸感,可以给亮度加一道gamma修正。最简单的方式是建一个256字节的查找表,把所有可能用到的亮度值预先映射一遍:

uint8_t gamma8[256]; void build_gamma_table(float gamma) { for (int i = 0; i < 256; i++) { float normalized = (float)i / 255.0f; gamma8[i] = (uint8_t)(powf(normalized, gamma) * 255.0f + 0.5f); } }

gamma值取2.2到2.4之间比较合适。映射之后,颜色分量更新时直接用gamma8[原始值]查表即可。这个优化对呼吸灯观感的提升非常明显,几乎和时序优化一样重要。

4.4 颜色漂移不只是软件问题:电源与地线

灯珠的颜色在低亮度区域出现轻微偏暖,或者灯带后段颜色偏暗,这是典型的电源压降问题。WS2812工作时瞬时电流变化很大,如果供电线的线径太细,或者供电线太长,灯珠全亮瞬间会把电压拉低。电压一低,灯珠内部逻辑判定阈值也跟着变化,颜色自然就偏了。

处理办法也很直接:

  • 灯带供电使用独立的5V电源,电流余量留1.5倍以上
  • 供电线尽量短、尽量粗,杜邦线慎用,推荐直接焊接或使用28AWG以上的线
  • 数据线串一个220Ω电阻靠近灯带放置,抑制信号振铃
  • STM32和灯带之间必须共地,否则数据信号无法构成回路

我遇到过一位开发者,折腾了很久颜色不准,最后发现是灯带供电用的是一根细长的红色杜邦线,换成粗短线后问题立刻消失。这类问题排查起来特别耗时,所以供电设计最好一开始就做对。

5. 从呼吸灯到多灯珠动画:扩展思路与性能边界

5.1 多灯珠下的缓冲区规划

呼吸灯做出来后,你已经掌握了一个可以驱动任意数量WS2812灯珠的完整链路。接下来要做多灯珠动画,只需要改变缓冲区填充逻辑即可。

一个非常加分的扩展是“呼吸波浪”效果:每颗灯珠的呼吸相位不同,从灯带一端到另一端依次亮起,形成一个连续传播的波浪。实现方法就是把每颗灯珠的phase加一个偏移量:

void ws2812_set_breath_wave(float basePhase) { for (uint16_t i = 0; i < LED_COUNT; i++) { float phase = basePhase + (float)i * 0.15f; float brightness = (sinf(phase) + 1.0f) * 0.5f; brightness = 0.05f + 0.95f * brightness; ws2812_set_color(i, (uint8_t)(255.0f * brightness), (uint8_t)(160.0f * brightness), (uint8_t)(80.0f * brightness)); } }

这样20ms更新一次缓冲区,每一帧的相位偏移固定不变,波浪效果就会稳定地从头流到尾。

5.2 动画帧率与CPU占用的平衡

WS2812作为单线协议,数据量随灯珠数量线性增长。一帧数据发送耗时可以用这个公式估算:

一帧耗时 = LED_COUNT × 24 × 1.25us

16颗灯珠耗时0.48ms,60颗灯珠耗时1.8ms,144颗灯珠耗时4.32ms。如果动画帧率是50fps,也就是20ms一帧,那么144颗灯珠的发送也只占约21.6%的时间。CPU在其余时间可以放心地做动画计算、状态机切换和其他外设任务。

但要注意,这个估算没有包含RESET期间的60us,也没有包含缓冲区填充耗时。缓冲区填充是纯软件操作,144颗灯珠的缓冲区有3456个元素,每次更新颜色时都要逐位判断并写CCR值,这个过程的CPU占用也不容忽视。优化办法是减少无意义的重复计算,例如给每颗灯珠保存独立的GRB值,只有颜色变化时才重新生成CCR序列。

5.3 同一件事的其他解法:SPI+DMA与专用外设

PWM+DMA不是唯一的选择,但它是在STM32上最省外设资源、最直观的一种方案。简单对比一下常见的其他方案:

方案原理优点缺点
PWM+DMA(本文)定时器PWM的CCR值由DMA逐周期更新占用外设少,逻辑直观需要在帧间手动处理RESET
SPI+DMA把MOSI波形当成WS2812时序,用多个SPI bit模拟一个WS2812 bit时序由硬件保证,稳定性极高缓冲区膨胀3到4倍,SPI速率要求高
GPIO+中断/定时器在定时器中断里翻转GPIO实现简单,引脚任意灯珠多、帧率高时CPU负载大
ESP32 RMT专为这类脉冲协议设计的外设驱动代码简单,CPU几乎零负载只有ESP32等特定芯片具备
RP2040 PIO用可编程I/O状态机产生精确时序非常灵活,可模拟任意协议需要一定的状态机编程经验

如果你手头只有STM32,而且希望方案的通用性更强,SPI+DMA也值得尝试。它的思路是把每个WS2812 bit用8个SPI bit表示:0码对应0b10000000,1码对应0b11100000,SPI时钟设为6.4MHz左右,这样每组8个SPI bit正好是1.25us。缺点是缓冲区大小变成原来的8倍,144颗灯珠就需要3456字节×3(RGB)约10KB,在F103上会吃紧。如果灯珠数量在60颗以内,这个方案同样很稳定。

从我个人的使用习惯来说,PWM+DMA在代码可读性和可维护性上更优,调试时用逻辑分析仪测量PA8引脚的波形,也能很直观地看到CCR值的变化是否符合预期。

最后分享一个我后来一直沿用的习惯:无论做呼吸灯还是跑马灯,都把“缓冲区更新”和“DMA传输”拆成两个独立函数,让发送完全由DMA中断驱动,主循环只做“算颜色”这一件事。这样后续加任何动画效果,都不会破坏发送时序。如果条件允许,再上乒乓双缓冲,流畅度还能再上一个台阶。希望这套方案也能让你的灯带真正“呼吸”起来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 1:59:31

new Object() 到底做了啥?

全文目录&#xff1a;开篇语0. 前言&#xff1a;那个让人心潮澎湃的 new 关键字&#xff01;1. Java 对象&#xff1a;从“胚胎”到“呱呱坠地”的曲折过程阶段一&#xff1a;类加载检查 (Class Loading Check)阶段二&#xff1a;分配内存 (Allocate Memory)阶段三&#xff1a;…

作者头像 李华
网站建设 2026/10/5 1:55:53

OpenCore Legacy Patcher 指南:5 步给老 Mac 装上新版 macOS

OpenCore Legacy Patcher 指南&#xff1a;5 步给老 Mac 装上新版 macOS 【免费下载链接】OpenCore-Legacy-Patcher Experience macOS just like before 项目地址: https://gitcode.com/GitHub_Trending/op/OpenCore-Legacy-Patcher 2013 款的 iMac 收不到系统更新&…

作者头像 李华