基于PIC32 MCU的嵌入式吉他游戏开发实战

📅 2026/8/27 1:21:42
基于PIC32 MCU的嵌入式吉他游戏开发实战
这是我从零做的一个基于 PIC32 MCU 的吉他游戏项目。整体思路、电路、代码都在下面了踩过的坑也都标出来了。想自己复刻一个的直接照着抄就行。1. 项目整体设计与方案选型1.1 先想清楚一个吉他游戏到底要 MCU 干什么这个项目的目标很明确用 MCU 做一个类似《吉他英雄》的嵌入式游戏机。外形是一把吉他琴颈上有 5 个彩色键位玩家需要配合拨片开关在屏幕上滚动的音符到达判定线时按下对应的键。整个系统完全跑在单片机上不依赖电脑。从需求拆分看MCU 端要做的事并不少实时扫描 5 个按钮加 1 个拨片开关根据谱面时间触发音符发声PWM 产生音调驱动一块小屏幕滚动渲染谱面跑游戏逻辑判定命中、计分、连击、失败状态处理开始、暂停、结束等交互这套需求覆盖了 MCU 开发的几个核心模块GPIO 输入、定时器中断、PWM 输出、I2C/SPI 外设通信、状态机设计。与其说是做游戏不如说是把嵌入式系统里最常见的几个知识点串起来做一次综合实战。适合处于进阶阶段的 MCU 开发者也适合想从“点灯”迈入“完整小产品”的电子爱好者。我第一次跑通整个流程的时候最大的感受是这个项目把 MCU 开发里最容易忽略的“边界”问题全部暴露出来了——按键抖动、时钟配置、中断嵌套、外设初始化顺序每一个都能让系统表现得很“玄学”。这些问题不亲手做一遍光看 datasheet 是永远理解不了的。1.2 为什么是 PIC32而不是 Arduino 或 STM32选 PIC32 之前我其实也纠结过。Arduino 上手快STM32 资料多PIC32 相对小众。但实际比较下来PIC32 在这个项目里有两个别人替代不了的优势一是它带硬件 PWMOC 模块生成音符频率完全不需要 CPU 干预二是 Microchip 的 MPLAB Harmony 图形化配置工具能把时钟树、引脚映射、外设初始化这种最容易出错的环节用图形界面搞定同时又保留了寄存器级的可读性。我用的是 PIC32MX270F512B主频 80MHz512KB Flash128KB RAM价格在 15 到 25 元之间。这个资源量跑一个带音效和滚动画面的游戏完全够用。维度PIC32MX270F512BArduino UNOSTM32F103C8T6内核MIPS M4KAVR 8位Cortex-M3主频80MHz16MHz72MHzFlash/RAM512KB / 128KB32KB / 2KB64KB / 20KB硬件PWM通道5个OC输出6路8位4个高级定时器开发环境MPLAB X XC32Arduino IDEKeil/STM32Cube上手难度中等低中等偏高说句公道话如果只是想做一轮快速原型验证Arduino 也不是不行UNO 的 16MHz 跑一个按键扫描加串口输出没什么压力。但问题出在音频和画面同时跑的时候AVR 的 8 位架构没有硬件 PWM 分频输出靠定时器中断翻转 IO 会严重挤占 CPU 时间画面帧率会掉到惨不忍睹。STM32 当然也能做但 STM32 的时钟树配置对新手第一次上手不太友好而且它的引脚复用逻辑和 PIC32 的 PPS 机制差异较大生态虽好学习曲线其实没有想象中平缓。PIC32 对我个人来说还有一个隐形优势MPLAB Harmony 的图形化配置界面会逼着你去理解“时钟从哪里来、外设总线怎么分频”这件事。很多教程直接给你一个SystemClock_Config()函数你复制粘贴跑了但完全不理解背后发生了什么。而在 Harmony 里你点选外部晶振 8MHz、PLL 倍频、PB 分频的每一个步骤生成的代码都能和硬件寄存器一一对上。1.3 系统整体架构硬件连接结构如下主控PIC32MX270F512B外部 8MHz 晶振锁相环倍频到 80MHz 系统时钟电源USB 5V 输入经 AMS1117-3.3 稳压到 3.3V输入琴颈 5 个轻触按键 1 个拨片开关全部使用内部上拉按下接地音频OC1 PWM 输出经 RC 低通滤波后接耳机放大器显示I2C 接口 OLED 128x64也可选 SPI 彩屏调试UART1 通过 USB-TTL 连接 PC打印日志软件分三层设计驱动层GPIO 扫描、Timer1 产生 1ms 系统 tick、OC1 输出音符 PWM、SSD1306 显示驱动、UART 打印游戏引擎层谱面数组、当前播放时间推进、命中判定、计分与连击逻辑表现层滚动画面渲染、音符触发音效、状态切换界面我在设计时故意把“游戏逻辑”和“外设驱动”分开。这样做的原因很实际外设驱动是可复用的底层代码以后做别的项目能直接搬游戏逻辑则完全是业务代码迭代非常频繁。如果混在一起写改一个判定窗口都要在中断里翻半天代码效率太低。分层之后调判定只需要改judge_window_ms一个变量调音色只需要该驱动层的 PWM 占空比初始化函数。2. 硬件电路设计MCU 最小系统到输入输出2.1 MCU 最小系统每个引脚的讲究PIC32 的最小系统看起来和普通单片机差不多但有几个细节对稳定性影响很大我一个个说。供电部分。PIC32MX 系列的工作电压是 2.3V 到 3.6V我统一用 3.3V。整个系统从 USB 的 5V 取电经过 AMS1117-3.3 降压。注意 AMS1117 的压差大约是 1V输入 5V 输出 3.3V 没问题但输入不能低于 4.3V否则稳压输出会掉。输入和输出端各加两个电容输入侧 10uF 钽电容 0.1uF 瓷片输出侧同样 10uF 0.1uF这是数据手册推荐的做法不要省。每个电源引脚都要独立去耦。PIC32MX270F512B 有多个 VDD/VSS 引脚对每个对的旁边都要放一个 0.1uF 瓷片电容并且尽量靠近引脚走线要短。原因在于MCU 内部时钟翻转瞬间电流变化率很高如果没有就近的低阻抗储能电容电源线上会产生毛刺。轻则外设工作不稳定重则不明原因复位。这个我踩过坑——一开始图省事只在一个电源引脚放了电容结果一播放音符 PWM 负载变化就复位后来把每个电源脚都补上 0.1uF问题彻底消失。时钟电路。外部晶振用 8MHz两个 20pF 负载电容分别接晶振两端到地。这里有个常见误区晶振负载电容不是随便选的要根据晶振的负载电容值 CL 计算。一般 8MHz 晶振的 CL 在 18pF 到 20pF公式是 CL (C1 x C2) / (C1 C2) 杂散电容两个电容相等时每个取 2 倍的 CL 附近所以 18~20pF 是常用值。复位电路。MCLR 引脚直接接一个 10k 电阻上拉到 3.3V对地接一个 100nF 电容。这个 RC 组合的作用是上电后让复位引脚维持一段低电平时间确保电源稳定后再释放复位。容量太大比如 1uF会导致上电启动变慢太小比如 1nF则可能抗干扰不足。100nF 是 Microchip 官方参考设计的值实测很稳。ICSP 调试接口。引出一个 6 针排针MCLR、PGD、PGC、VDD、GND外加一个辅助引脚。我用 PICkit4 烧录调试没有这个调试口程序写进去就是瞎子摸象。2.2 按键与拨片输入上拉内部还是外部按键方案有两种选择每个按键占一个独立 IO或者用 ADC 分压方式把所有按键接在一个引脚上。我两个都试过各有优劣展开说说。独立 IO 方式。5 个按键 1 个拨片共 6 个 GPIO。最简单的接法是按键一端接 IO另一端接地IO 配置为内部上拉。PIC32 的上拉寄存器是 CNPUEChange Notification Pull-up Enable把对应的位写 1 就开启了内部上拉。软件里读到低电平就是按下。这种方案最直观排查也容易缺点是占用的 IO 多而且如果项目要扩展其他功能比如加陀螺仪IO 就紧张了。ADC 分压方式。用一个 ADC 引脚外接一组电阻分压网络每个按键按下时把分压点拉到不同的电压。程序读取 ADC 值再根据电压区间判断按的是哪个键。PIC32 的 ADC 是 10 位逐次逼近型SAR ADC内部有一个采样保持电容通过比较器逐位逼近输入电压转换结果存入 ADC 数据寄存器。ADC 分压原理不复杂假设 3.3V 供电接一串 4.7k 电阻每个按键把不同节点短路到 ADC 引脚按下不同键ADC 引脚电压就在 0V 到 3.3V 之间呈阶梯分布。根据 ADC 读数落在哪个区间判断键值区间阈值要留足够余量避免电阻误差导致误判。我在最终版本里用的是独立 IO 方案。原因很实际项目在原型阶段IO 够用独立 IO 出问题好排查。但 ADC 方案的思路值得保留如果你想做“拨弦力度检测”也就是根据你拨动吉他弦的力度大小控制音量就需要用 ADC 实时采样压电传感器的模拟信号这个以后可以扩展。这里还牵扯出一个电源和信号完整性细节如果按键走线较长建议在按键并联一个 100nF 电容滤除空间耦合的噪声。特别是当 PWM 音频输出线从旁边经过的时候如果没有这个电容按下按键瞬间可能出现误触发。2.3 音频输出电路PWM 变声音的秘密PIC32 的 OC 模块可以生成 PWM 波形。单纯从 IO 输出 PWM 时直接接蜂鸣器就能听到声音但蜂鸣器出来的音色是刺耳的方波。想要更接近音符的柔音色需要把 PWM 方波里的高频分量滤掉。PWM 的载波频率我设置得比较高大约 31kHz音符频率范围是 262HzC4到 1047HzC6中间隔着很大的空档。用低通滤波器把 31kHz 的 PWM 高频载波衰减掉剩下的就是音符基频的近似正弦波。滤波器是二阶 RC 低通第一级 R1kΩ C33nF截止频率约 4.8kHz第二级同样参数两级串联后截止频率在 3.3kHz 左右。这个截止频率远高于音符最高频 1kHz所以音符本身不会被衰减但 31kHz 的 PWM 载波会被削掉一大截。下面是游戏里常用的音阶频率表音符频率Hz音符频率HzC4262G4392D4294A4440E4330B4494F4349C5523滤波后如果想驱动耳机需要一个放大器。最简单的做法是外接一个 PAM8403 功放模块3W 双声道3.3V 供电可用成本几块钱把滤波后的信号接进去输出接 3.5mm 耳机座。如果只是用带功放的有源音箱直接从滤波输出引线进 3.5mm 插头就行放大都在音箱里。我做的时候先直接用耳机听了滤波后的信号发现音量偏小且低音很弱后来才知道耳机需要电流驱动MCU 引脚只能提供电压信号推不动。加 PAM8403 之后音量马上正常低音也出来了。这个教训说明PWM 输出只负责“波形正确”功率放大是另一回事。2.4 显示与调试通道显示模块我选了 0.96 寸 I2C OLEDSSD1306 控制器128x64理由是接线少VCC/GND/SCL/SDA 四根线代码也简单。OLED 的刷新率大约 30fps对游戏画面来说勉强够用但如果你想要更流畅的滚动效果建议换成 SPI 接口的 1.8 寸 ST7735 彩屏SPI 速率可以跑到 20MHz 以上帧率翻倍。调试通道用的是 UART1波特率 115200。在这里我要特别说一个很多人忽略的问题MCU 串口接收引脚是否需要上拉。UART 协议里空闲状态的高电平是由发送端维持的。但如果接收端MCU 的 RX 引脚在烧录器或 USB-TTL 模块未上电时悬空这个引脚的电压是不确定的可能被噪声拉低导致 MCU 的 UART 外设误认为收到一个 0x00。这种情况的表现是你一打开串口助手就看到一堆乱码而且烧完程序后只要 USB-TTL 没接好同样的现象也会出现。解决办法很简单给 MCU 的 UART1 RX 引脚加一个 10k 上拉到 3.3V确保模块未上电时 RX 保持高电平这样 UART 不会空闲触发接收中断。另外很多 USB-TTL 模块的 TXD 默认就是高电平但加了上拉更保险。关于画 PCB如果做完原型想做成正式板子在 Cadence OrCAD 里画原理图时有个建议是用 CIS 功能核对 PIC32 的引脚信息。把 MCU 的 datasheet 引脚表导出来和原理图符号逐个比对特别是 ICSP、PWM 映射引脚、电源引脚的位置防止封装符号和实际芯片引脚错位。我之前吃过这个亏——OrCAD 符号画到 UART 引脚接反板子打回来才发现通信完全不通只能飞线非常被动。3. 软件架构与 MCU 启动流程3.1 MCU 启动流程到底是什么很多 MCU 教程默认你会启动流程直接上来就初始化外设导致不少人遇到“我的代码为什么没跑起来”时无从下手。这里展开讲讲 PIC32 的启动流程理解了它你调试所有 MCU 都会豁然开朗。PIC32MX 上电后的启动步骤可以拆成六步上电复位POR内部电路检测电源电压上升到阈值产生复位信号程序计数器 PC 跳到复位向量0xBFC00000这是 Boot Flash 的起始地址。执行引导启动代码芯片固化的 Bootloader 开始运行设置栈指针SP、初始化配置位Configuration Bits。配置位决定系统用什么主振荡器、是否启用看门狗、Flash 等待状态等。运行 C 运行时启动代码crt0这步是 C 编译器加的。负责把.data段从 Flash 拷贝到 RAM把.bss段清零建立 C 语言运行环境。也就是说你代码里定义的所有全局变量在这一步之后才有确定的值。跳转到 main()crt0 结束后程序进入用户主程序。main 里做系统时钟切换PIC32 上电默认用内部快速 RCFRC振荡器运行频率大约是 8MHz。如果想要 80MHz 主频必须在 main 里把时钟源切换到外部晶振 PLL并且配置好分频比。初始化外设并进入主循环GPIO、定时器、PWM、UART 等逐项初始化最后while(1)循环运行游戏逻辑。用乐队上台来类比电源稳定相当于调音台通电配置位是乐队定调子决定主频、看门狗crt0 是把乐器从车上搬下来摆好位main 里初始化外设是挨个试音最后进入主循环才是正式演出。很多人遇到“UART 波特率翻倍乱码”“定时器时间不对”这类问题十有八九是时钟切换没做或者做错了——程序里以为跑在 80MHz实际还在 8MHz所有基于时间的计算全部错乱。所以我的习惯是新项目启动后的第一件事就是先把时钟树捋清外晶振输入多少、PLL 倍频多少、PB 外设总线分频多少、各外设挂在哪条总线上全部写在注释里。3.2 软件框架中断驱动的超级循环这套软件架构我用的是“超级循环 定时器中断”的方案。主循环while(1)跑游戏状态机Timer1 产生 1ms 的节拍中断在中断里做按键扫描和游戏计时。为什么要把按键扫描放在中断里因为主循环的执行时间是不确定的——如果这一帧渲染画面比较复杂循环一次可能要花 30ms这时候按键扫描就会卡顿。放在 1ms 中断里无论主循环多忙按键采样频率都是稳定的不会丢按键事件。中断里我只做三件事维护system_tick变量每次中断加 1调用key_scan()判断当前扫描值是否有变化将“按下/松开”事件写入事件队列检查当前时间和谱面时间的差值如果命中窗口触发把命中结果写入事件队列游戏逻辑和画面渲染放在主循环里从事件队列取数据执行对应的状态变化。这个设计的关键是中断里不处理复杂逻辑只产生事件。如果中断里直接改游戏状态很容易出现主循环正在渲染一帧、中断突然把状态改了的情况画面上就会出现“一半新状态一半旧状态”的撕裂。事件队列做一个简单的数组环形缓冲主循环空了就消费既不会丢事件也不会造成数据竞争。我用了两个数组event_type[8]和event_param[8]配合头尾指针实现代码量不大但很可靠。主循环中的游戏状态机是典型的三段式typedef enum { STATE_BOOT, STATE_TITLE, STATE_PLAYING, STATE_OVER } game_state_t; game_state_t g_state STATE_BOOT; while (1) { switch (g_state) { case STATE_TITLE: render_title(); if (start_pressed()) g_state STATE_PLAYING; break; case STATE_PLAYING: render_notes(); process_events(); break; case STATE_OVER: render_score(); if (restart_pressed()) game_reset(); break; default: break; } }状态机的价值在于把不同阶段的逻辑隔离。标题界面不需要判定逻辑游玩阶段不需要处理开始按键这样每个 case 里代码都很干净。3.3 游戏逻辑谱面、判定与计分谱面是一组按时间排序的音符数组。传统谱面通常用时间戳从歌曲开始算的毫秒数加键位编号来表示我用的是结构体数组typedef struct { uint32_t time_ms; // 从歌曲开始的时间戳单位 ms uint8_t key_id; // 0~4 对应琴颈 5 个按键 uint16_t duration; // 长按持续时间0 表示单次点按 } note_t; // 一小段示例谱面BPM 120 的八分音符每个音符间隔 250ms const note_t song_1[] { { 1000, 0, 0 }, { 1250, 1, 0 }, { 1500, 2, 0 }, { 1750, 3, 0 }, { 2000, 4, 300 }, // 长按 300ms { 2500, 0, 0 }, { 2750, 1, 0 }, // ... 更多 }; const uint32_t song_1_len sizeof(song_1) / sizeof(song_1[0]);这里很多人的误区是把音符位置当“帧数”存。错歌曲速度如果变化帧数和毫秒对不上判定就全乱了。用时间戳存游戏里再根据当前累计播放毫秒数去索引天然和速度无关。判定逻辑是这样的假设判定窗口是 ±150ms。程序维护一个next_note_index指向当前应该判定的音符。如果玩家按下了某个键就检查这个键对应的 note 的时间戳和当前时间的差值#define JUDGE_WINDOW_MS 150 int32_t delta (int32_t)(g_tick_ms - song_1[next_note_index].time_ms); if (delta -JUDGE_WINDOW_MS) { // 按太早等待下一次按键不消费这个note } else if (delta JUDGE_WINDOW_MS) { // 超过窗口miss g_combo 0; next_note_index; } else { // 命中 g_score 100 (g_combo 0 ? (g_combo - 1) * 5 : 0); g_combo; next_note_index; tone_play(song_1[next_note_index - 1].key_id, 100); }计分规则我做的是单次命中基础分 100连击会加分每增加 1 连击额外加 5 分。这个规则简单但足够带动玩家追求高连击的欲望。注意 long 按duration 0的逻辑按下后不能立刻消耗这个 note要等玩家按住不松直到g_tick_ms超过note.time_ms duration才算完成。中途松开就是 miss。实现方式是检测到长按触发后进入一个holding状态在每次 tick 里检查key_state[key_id]是否仍然为按下直到时间耗尽。3.4 音频引擎从音符到声音用 PWM 生成音符频率核心是让定时器周期等于音符周期占空比 50%这样输出就是频率精确的方波。PIC32 里用 OC1 模块配合 Timer2 实现OC1 会自动根据占空比比较匹配翻转引脚不消耗 CPU。void tone_play(uint32_t freq_hz) { // 将定时器2预分频设为1使用PB时钟40MHz T2CON 0; // 先停止定时器 TMR2 0; PR2 (40000000UL / freq_hz) - 1; // 定时器周期 音符周期 OC1RS PR2 / 2; // 50% 占空比 OC1R PR2 / 2; T2CON 0x8000; // 使能定时器 }为什么用硬件 OC 而不是在中断里翻转 IO因为中断翻转受其他中断影响频率会产生抖动音色会发飘而且每翻转一次就要进一次中断80MHz 的 CPU 也经不起 1kHz 音符频率 × 2 次翻转变换的折腾。硬件 PWM 从原理上就规避了这两个问题定时器溢出自动翻转引脚稳定且不占用 CPU。我在实际项目中还做了一个小优化长按音延续时每次 tick 都检查g_tick_ms如果按键还按着就持续给 OC1 使能一旦松开立即调用tone_stop()关闭输出。这个“松手即停”的响应速度直接决定了游戏的“手感”做不好会有明显的延迟感。如果你想进一步改善音色可以给音符加“包络”envelope也就是在发声开始的 10ms 内把 PWM 占空比从 0 慢慢升到 50%结束后 10ms 内再降下来。这样做的好处是避免占空比突变产生的“噼啪”爆音尤其是长按接短按时效果立竿见影。4. 实操过程与核心环节实现4.1 开发环境搭建两小时入门 MPLAB X Harmony工具链我用的是 MPLAB X IDE 6.x XC32 编译器 4.x配合 Harmony 3 配置外设。Harmony 对于新手来说稍微有点重但优势是可以图形化配置时钟树和引脚复用PPS生成初始化代码后你再去查看生成的clock_config.c能清楚看到每一步对应哪些寄存器。创建项目的步骤打开 MPLAB XFile - New Project选择Microchip Embedded - 32-bit MCC Harmony Project或者用传统带 Harmony 插件的项目向导。芯片型号选择 PIC32MX270F512B。打开 MCCMPLAB Code Configurator在Clock页面配置主时钟源选 Primary Oscillator外部晶振晶振频率输 8MHz开 PLL目标系统主频设 80MHz外设总线 PB 时钟设 40MHz。在Pin页面把需要的引脚分配好特别是 PPS 映射UART1 的 TX/RX、OC1 输出、I2C1 的 SCL/SDA。PIC32 的外设引脚映射Peripheral Pin Select是必须要配的不配的话外设功能不会出现在你期望的引脚上。Generate生成代码然后在你自己的 main.c 里添加游戏逻辑。这里插一句关于 PPS 的坑PIC32 的同一组外设可以映射到很多不同的引脚但对初学者来说这不是方便而是灾难。如果你发现 UART 不工作、OLED 没反应第一步检查 PPS 映射是否正确。MCC 里配完建议去生成的代码里搜PPS或MAP相关寄存器确认映射值是你想要的。4.2 按键扫描与防抖不能直接读引脚按键电路的硬件上有了上拉按下就是低电平但软件不能直接读一次就算数。机械按键在按下和松开的瞬间簧片会弹跳电平在几个毫秒到几十毫秒内反复变化直接按这个值判断一次按键会被识别成多次。我用的防抖方案是“连续 3 次采样一致才确认”。在 1ms tick 中断里每次读取原始值和上一次采样值比较如果相同就计数 1计数到 3 才切换状态。这样等效于 3ms 的防抖时间。对于大多数轻触开关3ms 足够了如果你的按键特别差可以提高到 5ms 或 10ms。实现代码#define KEY_COUNT 6 // 5个音符键 1个拨片 static uint8_t key_sample[KEY_COUNT]; // 当前采样值 static uint8_t key_prev[KEY_COUNT]; // 上一次采样值 static uint8_t key_state[KEY_COUNT]; // 稳定后的状态0松开1按下 static uint8_t key_debounce_cnt[KEY_COUNT]; void key_scan(void) { uint8_t raw[KEY_COUNT] { PORT_read(KEY1), PORT_read(KEY2), PORT_read(KEY3), PORT_read(KEY4), PORT_read(KEY5), PORT_read(BAR) }; for (int i 0; i KEY_COUNT; i) { if (raw[i] key_sample[i]) { if (key_debounce_cnt[i] 3) { key_debounce_cnt[i]; } if (key_debounce_cnt[i] 3) { if (key_state[i] ! key_sample[i]) { key_state[i] key_sample[i]; // 0: 松开沿, 1: 按下沿 event_queue_push(i, key_state[i]); } } } else { key_sample[i] raw[i]; key_debounce_cnt[i] 0; } } }这个写法每次扫描只有几次比较和自增执行时间在微秒级放到 1ms 中断里毫无压力。事件队列里的“按下沿”和“松开沿”是游戏逻辑需要的两个关键信息按下沿触发音符判定和发声松开沿负责长按结束检测。4.3 PWM 发声从初始化到完整控制OC1 初始化要绑定时钟源和引脚。MCC 里配置完会生成类似代码核心逻辑如下void oc1_init(void) { // 关闭 OC1配置为 PWM 模式 OC1CON 0; OC1R 0; OC1RS 0; // 使用 Timer2 作为时钟源PWM 模式OCM 110 OC1CONbits.OCM 0b110; OC1CONbits.OCTSEL 0; // 使用 Timer2 OC1CONbits.ON 1; }注意OC1 的 PWM 引脚输出位置是由RPOR寄存器决定的MCC 里选择好输出引脚后会在初始化代码里把对应的 PPS 映射写进去。这部分千万不能省否则你在OC1CON上看到一切正常输出引脚却没信号。音频的完整控制逻辑是游戏里某个音符命中时根据 key_id 查表得到频率调用tone_play(freq)长按期间每个 tick 检查按键状态松开或者音符时间结束调用tone_stop()。void tone_stop(void) { OC1RS 0; OC1R 0; }这里有个小细节OC1RS是影子寄存器会在下一次定时器周期匹配时自动加载到OC1R所以你可以随时修改OC1RS改变占空比而不影响当前周期。这是实现淡入淡出包络的关键机制如果你想做一个滑音效果只需要在每次 tick 里把OC1RS的值逐步增大或减小即可。4.4 显示与主循环谱面是怎么滚动的OLED 的驱动用 SSD1306 的标准 I2C 协议128x64 分辨率。游戏画面的核心是“滚动谱面”的渲染逻辑屏幕上左侧画判定线5 条轨道对应 5 个按键音符方块从右侧出现向左侧的判定线移动。音符方块的 x 坐标和时间的关系是int note_x SCREEN_W - (int)((g_tick_ms - note.time_ms) / speed_px_per_ms); int note_y 8 note.key_id * (SCREEN_H - 8) / 5;这里的speed_px_per_ms是一个常量控制音符在屏幕上移动的速度。假设你想让 1 秒时间差对应 30 像素那么speed_px_per_ms 0.03。1000ms 前出现的音符在屏幕最右侧当前时刻到达判定线的音符在屏幕最左侧。渲染一帧的流程清空帧缓冲绘制 5 条轨道线遍历谱面数组找出time_ms在“当前时间往前 2 秒”到“当前时间往后 1 秒”范围内的音符逐个计算 x 坐标并绘制绘制判定线和当前分数、连击数将帧缓冲一次性传给 OLED主循环里我做了帧率控制用g_tick_ms判断距离上次刷屏是否超过 20ms如果没到就继续处理事件。这样即使 OLED 刷新慢游戏逻辑判定也能保持 1000Hz 的精度跟画面帧率完全解耦。这是一个很容易被忽视的设计要点游戏逻辑最高优先级画面帧率可以降但判定不能降。如果同步刷新OLED 刷新动画慢的时候判定也会变慢玩家按了键但没反应体验极差。分开后判定走事件队列画面只管渲染互不干扰。4.5 完整程序流程整合把以上模块串起来main()的流程是int main(void) { SYSTEM_Initialize(); // MCC生成时钟、GPIO、定时器、OC1、UART、I2C ssd1306_init(); game_reset(); g_state STATE_TITLE; while (1) { // 消费事件队列 while (event_queue_not_empty()) { event_t ev event_queue_pop(); game_handle_event(ev); } // 帧率控制20ms刷新一次画面 if (g_tick_ms - g_last_render_ms 20) { render_frame(); g_last_render_ms g_tick_ms; } } }Timer1 中断里只做三件事g_tick_mskey_scan()game_update_song_progress()。这个流程看着简单但把中断、队列、状态机、渲染四层关系理得很顺。调试的时候只要一个层级出问题立刻能根据现象定位到具体模块。5. 常见问题与排查技巧实录5.1 我踩过的坑第一个坑时钟树配置错误UART 乱码。项目刚开始我直接用默认 8MHz FRC 跑没有切换到 PLL波特率计算全错。现象是串口打印出来全是乱码调了波特率寄存器也没用。排查了一会儿才发现是主频根本不是我以为的 80MHz。后来学了乖在代码开头打印一条时钟信息包含OSCCON寄存器的值每次上电先用串口确认当前实际主频再继续往下跑。第二个坑PPS 端口映射没配置OLED 和 PWM 都没信号。这个问题非常隐蔽——GPIO 初始化明明设置了引脚方向但外设功能就是没反应。后来查 PIC32 的引脚复用说明才意识到 PPS 是独立于 GPIO 方向的必须在初始化时把 PWM 输出映射到具体引脚。MCC 里生成代码时自动配好了但如果你手写初始化非常容易漏掉这一行。第三个坑音频输出有“嗒嗒”的爆音。每次音符触发时耳机里都能听到一个很明显的冲击声。分析后发现两个原因叠加一是 PWM 占空比从 0 跳到 50% 是瞬时的产生一个高频冲击二是电源线路上没有足够的滤波电容导致音频信号上叠加了电源噪声。解决方案是音符的开始和结束阶段加包络逐步改变占空比同时给音频电路单独加一个 100uF 电解电容做电源滤波。第四个坑长按检测丢音。长按音符结束时如果玩家已经松手但程序还认为按着声音会持续一小段反过来玩家按住但程序检测到松开声音提前断。原因是长按的结束条件判断用了“当前时间大于等于音符时间戳加时长”但音符时间戳可能是从歌曲开始算的而g_tick_ms是系统时间两者如果不做偏移校准就会错位。解决方法是单独维护一个song_elapsed_ms在状态机进入 PLAYING 时才清零和谱面时间戳对齐即uint32_t song_elapsed_ms g_tick_ms - g_song_start_tick;5.2 问题速查表现象可能原因排查步骤解决方案上电无任何反应电源未正常、复位被拉低万用表测 3.3V、MCLR 电平检查 LDO 焊接确认 LDO 输入输出电容MCLR 上拉电阻UART 输出乱码主频不对、波特率计算错、RX 悬空串口打印时钟寄存器值示波器量 TX