news 2026/9/12 11:41:13

STM32F103驱动VS1053音乐播放器:SPI时序与无声排错全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103驱动VS1053音乐播放器:SPI时序与无声排错全解析

简介:一套基于STM32F103标准库开发的VS1053音乐播放器完整工程,面向单片机初学者及需要快速嵌入音频播放功能的开发者。压缩包共176个文件,以72个C源文件、71个头文件为主,另含若干PNG图片、TXT说明及工程配置文件,整体约1.65MB,目录划分清晰,方便按模块查阅。程序上电后先检查字库是否存在,随后对VS1053执行RAM测试与正弦测试,确保芯片正常后自动读取SD卡MUSIC文件夹内的歌曲并循环播放,配合TFTLCD显示歌名、播放进度、总时长和当前曲目编号等信息。交互方面,KEY0/KEY2实现上下曲切换,KEY_UP与KEY1调节音量,同时例程集成了USMART调试组件,可灵活调用VS_Set_Vol、VS_Set_Bass、VS_Set_Effect等函数来验证音频参数配置,对深入理解编解码芯片驱动与程序调试很有帮助。已有288人学习使用,适合作为STM32音频项目开发的直接参考例程。

1. 能直接编译只是入场券,stm32F103 例程真正难在 VS1053 的通信时序

解压一个 stm32F103 + VS1053 音乐播放器例程,双击工程直接编译,下载到最小系统板,耳机里没有任何声音——这类问题几乎每天都能在论坛里碰到。多数人不是不会点灯,而是把 VS1053 当成了普通 SPI Flash:以为读、写、片选一条路走到底。实际上这颗芯片把控制信息和音频数据流分成 SCI 与 SDI 两条独立通道,命令只走 XCS,数据只走 XDCS,两条片选一旦搞混,寄存器写不进、MP3 也喂不进去。下面这条路径会从固件库工程说起到验证方法收尾,把标准外设库的搭建、VS1053 寄存器初始化、SD 卡数据源与无声排错一起拆开讲,适合手里有现成例程但调不出声音的人按步骤复现。

2. VS1053 解码流程与 stm32F103 最小系统接线

VS1053 是一颗自带 DSP 和 DAC 的音频解码芯片,MP3、WAV、OGG 等格式的音频帧在芯片内部完成 Huffman 解码、逆 MDCT 和 PCM 输出,MCU 完全不参与浮点运算。F103C8T6 这类器件主频 72MHz,要跑 FATFS、读 SD 卡、驱动 LCD,如果再用软件解 MP3,码率稍高就会和文件系统互相抢时间,不如把解码交给专门的外设。所以在音乐播放器方案里,STM32F103 的角色不是“解码器”,而是“搬运工”:从 SD 卡读出压缩数据,按 VS1053 能接收的节奏通过 SPI 喂进去。

2.1 一首 MP3 从 SD 卡到 VS1053 芯片的数据路径

音乐文件由 F103 从 SD 卡按扇区读出,打包成 32 字节左右的块,通过 SPI 总线送给 VS1053。SPI 这端只有一条总线和一组时钟/数据线,但 VS1053 内部按用途把它分成两个从机:

通道片选引脚作用
SCIXCS写/读控制寄存器,初始化、音量、时钟配置都走这里
SDIXDCS接收 MP3/WAV 音频数据流,不进寄存器

这两条通道共用 SCK、MISO、MOSI,靠不同的片选来区分。XCS 拉低时发的是命令帧,XDCS 拉低时发的是音频裸数据。初始化阶段不碰 XDCS,播放阶段不碰 XCS,这是整个驱动代码的骨架。理解了这个骨架,后面所有函数都能对号入座。

2.2 stm32F103 最小系统板与 VS1053 模块的接线

VS1053 模块绝大多数是 3.3V 逻辑,STM32F103 最小系统板也以 3.3V 供电为主,两边可以直接直连。DREQ 是芯片输出给 MCU 的状态信号,必须用普通 GPIO 输入引脚;XCS、XDCS、XRESET 用普通 GPIO 输出。我一般这样分配:

VS1053 模块引脚STM32F103 引脚类型说明
SCKPA5SPI1_SCK复用推挽输出
MISOPA6SPI1_MISO浮空输入
MOSIPA7SPI1_MOSI复用推挽输出
XCSPA4GPIO 输出命令片选,低有效
XDCSPA3GPIO 输出数据片选,低有效
XRESETPA2GPIO 输出硬件复位,低有效
DREQPA1GPIO 输入高电平表示可以接收数据
3.3V3V3电源模块供电
GNDGND电源共地

PA4 在 SPI 外设里默认是 NSS 引脚,但只要软件把 SPI 配成SPI_NSS_Soft,PA4 就不再受外设控制,可以放心拿来当 GPIO 用。XRESET 必须由 MCU 的 GPIO 单独控制,不要直接和 STM32 的复位脚并在一起,不然上电顺序不一致,VS1053 可能起不来。

2.3 DREQ:VS1053 告诉单片机“可以发数据”的唯一信号

DREQ 是 VS1053 输出的一根状态线,高电平表示内部 FIFO 有空位,低电平表示芯片正忙。上电后 DREQ 不会立刻变高,它要等晶振起振、内部 ROM 引导完成,这个过程可能持续几十毫秒到一百毫秒。驱动代码里最常见的死法就是上电后直接while (DREQ == 0);,如果芯片没起来,程序就永久卡在这一行。更稳妥的做法是加超时:

uint32_t timeout = 1000000; VS1053_GPIO_Config(); SPI1_Init(); timeout = 1000000; while (DREQ_IsHigh() == 0 && timeout-- > 0) { delay_ms(1); } if (timeout == 0) { Error_Handler(); /* 硬件没就绪,不要再往下发命令 */ }

这段代码的逻辑是先给足等待时间,再判断是否超时。timeout--在每次循环里递减,如果 DREQ 一直不拉高,最终会走到Error_Handler,而不是死循环。之后的播放代码里,每次写数据和写命令之前也都要检查 DREQ,但那时不用加超时,因为初始化已经通过,芯片处于正常工作中。

3. 固件库工程搭建与 SPI 硬件初始化

标题里写明“库文件开发”,对应到 STM32F103 上就是标准外设库 V3.5.0,也就是常说的 SPL 固件库。这套库把寄存器操作封装成GPIO_InitSPI_InitRCC_APB2PeriphClockCmd这类函数,和现在 CubeMX 生成的 HAL 库是两套体系。老例程多是基于 SPL 写的,所以拿到一个 VS1053 工程,先别急着改代码,把工程依赖的文件结构确认清楚,编译不过的问题大多出在这一步。

3.1 拿到例程包之后先看清库的依赖

SPL 工程通常由四部分拼起来:CMSIS 启动文件、标准外设库驱动、用户代码、文件系统。文件系统不是必需的,但音乐播放器一般离不开 SD 卡,所以例程里大概率会带 FATFS。工程里的文件分组大致是这样:

分组关键文件作用
CMSISstartup_stm32f10x_md.s、system_stm32f10x.c启动代码和系统时钟初始化
StdPeriphstm32f10x_gpio.c、stm32f10x_spi.c、stm32f10x_rcc.c外设驱动库函数
FATFSff.c、ff.h、diskio.c文件系统与底层磁盘接口
Usermain.c、vs1053.c、sdcard.c业务代码

启动文件要和芯片容量匹配。F103C8T6 是 64KB Flash,属于中等容量,用startup_stm32f10x_md.s;如果是 F103ZET6 这种 512KB 的,用startup_stm32f10x_hd.s。启动文件选错,程序能编译但下载后跑不起来,现象很迷惑,所以每次换板子都要先看一眼这个文件。

3.2 SPI1 与片选引脚的库函数初始化

VS1053 的 SPI 接口要求 MSB 先发、模式 0(CPOL=0、CPHA=0),也就是时钟空闲为低,第一个边沿采样。初始化代码分成 GPIO 和 SPI 两部分,片选线用软件控制,不交给硬件 NSS。

void VS1053_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* DREQ 输入,初始下拉,防止悬空误判 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPD; GPIO_Init(GPIOA, &GPIO_InitStructure); /* XRESET、XDCS、XCS 三根控制线 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); XCS_High(); XDCS_High(); RESET_High(); } void SPI1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); /* SCK、MOSI 复用推挽输出 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); /* MISO 浮空输入 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex; SPI_InitStructure.SPI_Mode = SPI_Mode_Master; SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_16; SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }

参数说明:SPI_BaudRatePrescaler_16在 72MHz 的 APB2 时钟下得到 4.5MHz 的 SPI 时钟。这个速度对 VS1053 来说偏保守但安全。上电初期如果 DREQ 反复起不来,可以临时把分频改成SPI_BaudRatePrescaler_32(2.25MHz)试一下,初始化成功之后再提速。SPI_NSS_Soft很关键,它让 PA4 彻底变成普通 GPIO,否则 SPI 外设会自动拉低 NSS,干扰 XCS 的时序。

3.3 修改 stm32f10x_conf.h 与工程头文件路径

SPL 库用一个总配置文件stm32f10x_conf.h控制编译哪些外设驱动,里面是#define _GPIO#define _SPI这样的宏。VS1053 工程至少要打开_GPIO_SPI_RCC;如果 SD 卡用 SPI 模式,_SPI已经覆盖;如果用 SDIO 接口才需要_SDIO。每次换到新工程,我都会先搜一下这几个宏,防止库文件裁剪导致编译报 undefined。

Include Paths 里需要添加三个路径:Libraries/CMSISLibraries/STM32F10x_StdPeriph_Driver/incUser。漏掉任何一个,编译都会先报找不到stm32f10x.h。如果工程是从别的板子拷过来的,还要检查 Target 选项卡里的芯片型号,F103C8T6 和 F103ZET6 的 RAM、Flash 地址范围不同,选错型号下载时可能提示校验失败,这时先查 BOOT0 引脚有没有被拉低,再查器件型号。

4. VS1053 寄存器读写与初始化代码

VS1053 的控制通道 SCI 使用一种固定格式的命令帧,写操作是0x02开头,读操作是0x03开头,后面跟 16 位寄存器地址和 16 位数据。整个帧长度是 4 个字节,片选 XCS 低有效,帧结束后拉高。这是芯片数据手册里规定的时序,代码里没有太多自由发挥的空间,照着写就行。

4.1 SCI 命令帧的 4 字节结构

写一个寄存器的完整字节序列是:02 | AddrH | AddrL | DataH | DataL。读寄存器则是:03 | AddrH | AddrL | xx | xx,其中xx是 MCU 发送的空字节,用来产生 SCK 时钟,VS1053 会在 MISO 上同时返回数据。下面这段代码把读写封装成两个函数,后续所有寄存器操作都调它们。

uint8_t VS_SPI_RW(uint8_t byte) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, byte); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET); return SPI_I2S_ReceiveData(SPI1); } void VS1053_WriteCmd(uint16_t addr, uint16_t data) { while (DREQ_IsHigh() == 0); /* 命令周期开始前等 DREQ */ XCS_Low(); VS_SPI_RW(0x02); /* 写操作码 */ VS_SPI_RW(addr >> 8); VS_SPI_RW(addr & 0xFF); VS_SPI_RW(data >> 8); VS_SPI_RW(data & 0xFF); XCS_High(); } uint16_t VS1053_ReadCmd(uint16_t addr) { uint16_t val = 0; while (DREQ_IsHigh() == 0); XCS_Low(); VS_SPI_RW(0x03); /* 读操作码 */ VS_SPI_RW(addr >> 8); VS_SPI_RW(addr & 0xFF); val = VS_SPI_RW(0xFF) << 8; /* 高字节先返回 */ val |= VS_SPI_RW(0xFF); /* 低字节 */ XCS_High(); return val; }

VS_SPI_RW是全双工收发,发送一个字节的同时一定能收到一个字节,所以读时序里连续发送两个0xFF只是用来产生时钟。这里要注意:XCS 拉低之前必须保证 DREQ 为高,否则命令可能被芯片忽略。地址数据都是高位在前,这是 SPI 的SPI_FirstBit_MSB决定的,不能改成 LSB 先发。

4.2 完整的 VS1053 初始化序列

初始化要做的事按顺序是:硬件复位、软件复位、开启 New Mode、设置倍频、设置音量。硬件复位是把 XRESET 拉低再拉高;软件复位则是往 SPI 总线直接发送0xDE 0x49两个字节。然后通过 SCI 写0x0804到 MODE 寄存器,让芯片进入带SM_SDINEW的复位状态,延时后再写0x0800清除复位位。

void VS1053_Init(void) { RESET_Low(); delay_ms(20); RESET_High(); delay_ms(50); /* 等晶振起振和内部 ROM 引导 */ XCS_Low(); VS_SPI_RW(0xDE); /* VS1053 软复位序列 */ VS_SPI_RW(0x49); XCS_High(); delay_ms(50); VS1053_WriteCmd(SCI_MODE, 0x0804); /* SM_RESET + SM_SDINEW */ delay_ms(20); VS1053_WriteCmd(SCI_MODE, 0x0800); /* 清复位位 */ delay_ms(10); VS1053_WriteCmd(SCI_CLOCKF, 0x6000); /* 3 倍频 */ delay_ms(10); VS1053_WriteCmd(SCI_VOL, 0x4040); /* 左右声道音量 */ }

0x6000对应 CLOCKF 高三位011,也就是 3 倍频。如果模块的晶振是常见的 12.288MHz,3 倍频后内部主频约 36.864MHz,播放 128kbps 的 MP3 和 44.1kHz 的 WAV 都够用。网上有些代码直接写0xB800,那是 3.5 倍频,要先确认晶振频率再照搬,否则芯片工作在超频边缘,DREQ 行为会变得不稳定。SCI_VOL的 0x4040 也不是 40% 音量,这个寄存器 0x00 是最大声,0xFE 是最小声,0xFF 表示该声道无声。

4.3 VS1053 常用寄存器速查表

调试阶段手上有一张寄存器表,比反复看数据手册快得多。下面这几个是初始化必用、排错必查的:

地址寄存器常用值说明
0x00MODE0x0800SM_SDINEW必须置位,否则 MP3 可能无声或杂音
0x01STATUS只读芯片版本和状态
0x02BASS0x0000高低音调节,默认关闭
0x03CLOCKF0x6000倍频设置,12.288MHz 晶振用 3 倍
0x0BVOL0x4040左右声道音量,0x00 最大
0x04DECODE_TIME只读已解码时长

如果播放时只有刺耳的噪音,优先查 MODE 寄存器有没有写进0x0800。VS1053 上电默认不开启 New Mode,老模式对 MP3 的兼容性差,这是最容易踩的坑之一。

5. 从 SD 卡读 MP3 喂给 VS1053 的 SDI 数据通路

初始化完成后,芯片已经处于待播放状态,接下来要做的是把 SD 卡里的音频文件搬进 SDI。这个阶段不涉及解码,只看两件事:文件系统能不能读出数据,以及数据能不能按 DREQ 的节奏送进芯片。两者任何一个掉了链子,声音就断。

5.1 挂载 FATFS 并用 f_read 读块

SD 卡挂在 SPI1 上,和 VS1053 共用同一套 SPI 引脚,靠片选区分。使用 FATFS 时,外部 SD 卡和 VS1053 不能同时被选中:操作 SD 卡时 XCS、XDCS 都要保持高,操作 VS1053 时 SD 卡的片选要释放。下面是最小播放循环:

FATFS g_fs; FIL g_file; uint8_t g_audio_buf[32]; void Music_Play(const char *path) { UINT br; if (f_mount(&g_fs, "", 1) != FR_OK) { Error_Handler(); /* SD 卡挂载失败 */ } if (f_open(&g_file, path, FA_READ) != FR_OK) { Error_Handler(); /* 文件打开失败 */ } while (1) { if (f_read(&g_file, g_audio_buf, sizeof(g_audio_buf), &br) != FR_OK) { break; } if (br == 0) { break; /* 文件读完了 */ } VS1053_SdiSend(g_audio_buf, br); if (br < sizeof(g_audio_buf)) { break; /* 最后一包不足 32 字节 */ } } f_close(&g_file); }

f_mount的参数在不同版本的 FATFS 里有差异,老版本是f_mount(&fs, "", 1),新版本把卷号放在前面,编译不过就按头文件里的原型调整。f_read每次填满 32 字节缓冲区,返回br是实际读到的字节数,文件末尾的最后一包通常不足 32 字节,这时也要完整发给 VS1053,因为音频数据流必须连续,不能因为缓冲不满就丢掉尾部。

5.2 SDI 写入节奏:DREQ 高才发送,32 字节为一批

SDI 通道没有操作码和地址,XDCS 拉低后直接逐字节发送音频数据。发送节奏是关键:DREQ 高只代表芯片能接收,不代表可以无限灌数据。参考驱动里常用的做法是每次只发 32 字节,发完拉高 XDCS,再等下一次 DREQ。

void VS1053_SdiSend(const uint8_t *buf, uint16_t len) { uint16_t i; while (DREQ_IsHigh() == 0); /* 等 FIFO 空位 */ XDCS_Low(); for (i = 0; i < len; i++) { VS_SPI_RW(buf[i]); /* 整包连续送出 */ } XDCS_High(); }

这段代码里,32 字节以内的包在发送过程中不需要每字节都检查 DREQ,因为 VS1053 的 FIFO 足够容纳。如果单包大于 32 字节,就必须在包里分段等待 DREQ,否则会溢出。XDCS 在每包之间拉高是安全的习惯,因为切换到下一首歌时,VS1053 需要通过片选状态确定一个数据流的边界。

在带 FreeRTOS 的工程里,不要在一个低优先级任务里死等 DREQ,否则音频任务会拖累整个调度。常见做法是把音频读取放到独立任务,或者用 SPI DMA 搬运,DREQ 用外部中断通知。没有 RTOS 时也要在主循环里给其他任务留出执行窗口,不能一直卡在while (DREQ_IsHigh() == 0)

5.3 播放循环里的三个常见误区

无声、杂音、卡顿这三个现象各有各的根源。下面这个表是我排错时的第一反应:

现象可能原因第一检查点
完全无声音量寄存器方向搞反SCI_VOL写成大音量值
有声但像噪音没有置位SM_SDINEWMODE 寄存器值
播一会停一会SD 卡读取速度跟不上FATFS 缓冲区是否太小
偶发爆音切歌时 XDCS 没有拉高两首歌之间片选状态

音量寄存器是反逻辑的,这是新手最容易犯的错。0x0000才是最大声,第一次调试时建议直接写0x1010,先确认能听到声音,再往回调。卡顿问题则要从 SD 卡那边看,SPI 模式的 SD 卡读取速度上限不高,如果音频码率突然升高,32 字节缓冲会不够用,可以把缓冲区改成 512 字节,一次读一个扇区。

6. 回读寄存器自检:上电十分钟定位 VS1053 硬件问题

接好线、下载完程序,耳机没声音,这时不要急着改代码,先做一个寄存器回读自检。这个验证方法能区分问题在 SPI 链路还是在音频数据通路,比盲改参数高效得多。

6.1 先做可回读寄存器校验 SPI 链路

CLOCKF 和 VOL 都是可读可写寄存器,写进去能原样读出来,说明 MOSI、MISO、SCK、XCS 这条链路是通的。这个测试放在初始化之后执行:

uint8_t VS1053_LinkTest(void) { uint16_t clk, vol; VS1053_WriteCmd(SCI_CLOCKF, 0x6000); delay_ms(5); clk = VS1053_ReadCmd(SCI_CLOCKF); if (clk != 0x6000) { return 1; /* 时钟寄存器回读失败 */ } VS1053_WriteCmd(SCI_VOL, 0x4040); delay_ms(5); vol = VS1053_ReadCmd(SCI_VOL); if (vol != 0x4040) { return 2; /* 音量寄存器回读失败 */ } return 0; /* 链路正常 */ }

返回值 1 说明 SPI 时钟线或数据线有问题,先查 XCS 是不是被 SPI 外设的 NSS 功能占用了;返回值 2 则说明命令帧的时序不对,把 SPI 频率降到SPI_BaudRatePrescaler_32再测。回读如果得到0xFFFF,大概率是 MISO 没连好;如果得到0x0000,大概率是 VS1053 根本没工作,量一下 3.3V 电压和晶振引脚波形。

6.2 再用正弦测试确认模拟通路

链路测试通过后仍然无声,问题就从数字侧转移到了模拟侧。参考驱动里广泛使用一组 16 字节的正弦测试序列,直接通过 SDI 发给 VS1053,芯片识别后会输出一个接近 1kHz 的连续单音。这不依赖 SD 卡和 FATFS,可以把音源问题排除干净。

static const uint8_t sine_tone[16] = { 0x45, 0x78, 0x6D, 0x6D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00 }; void VS1053_SineTest(void) { while (DREQ_IsHigh() == 0); XDCS_Low(); for (int i = 0; i < 16; i++) { VS_SPI_RW(sine_tone[i]); } XDCS_High(); }

这 16 个字节不是音频帧,芯片固件识别开头特征后直接进入测试模式,耳机里应该出现连续单音。如果这时还没声音,问题基本不在 VS1053 的通信链路,而是要从功放芯片、耳机座、AVDD 滤波电容这些模拟通路上去查。回读测试和正弦测试都通过之后,再回到 FATFS 文件播放,剩下的事情就只是数据源快慢了。

本文还有配套的精品资源,点击获取

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

10分钟跑通CUDA程序:AMD显卡ZLUDA配置完整指南

10分钟跑通CUDA程序&#xff1a;AMD显卡ZLUDA配置完整指南 【免费下载链接】ZLUDA CUDA on non-NVIDIA GPUs 项目地址: https://gitcode.com/GitHub_Trending/zl/ZLUDA CUDA程序只能在N卡上跑&#xff0c;AMD卡干看着&#xff1f;ZLUDA 是一套“即插即用”的 CUDA 运行时…

作者头像 李华
网站建设 2026/9/12 11:38:35

Python文件式与交互式编程模式详解及实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 11:37:02

从VSCode扩展到独立桌面应用:Electron + Vue 3架构改造实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华