简介一套面向STM32初学者的矩阵键盘开发完整工程资源基于STM32F103芯片内容覆盖GPIO配置、行列扫描算法、外部中断触发与按键消抖处理等核心环节适合需要快速实现键盘输入功能的嵌入式开发者参考学习。压缩包共126个文件大小2.59MB以32个C源码和33个头文件为主附带Keil工程配置、编译生成的hex/axf文件以及说明文档便于直接打开工程查看或烧录验证。已有1471人浏览学习资源热度较高。通过这套工程可直观理解矩阵键盘与MCU的硬件连接方式掌握IO控制、行列扫描与中断响应的编程思路并可直接在此基础上修改以适应4x4或其他行列布局。1. 矩阵键盘为什么能省引脚从 16 键到 8 个 GPIO第一次拿到 4x4 矩阵键盘时我下意识数了一下16 个按键至少需要 16 个 IO 口。但 STM32F103 的 GPIO 资源本来就不算宽裕——一个项目里要挂 LCD、传感器、串口再为键盘掏出十几个引脚板子就得重新画。矩阵键盘的巧妙之处在于用行列交叉结构让 16 个按键只占 8 个 IO而且这个数字会随矩阵规模增长4x4 是 8 个引脚5x5 也就 10 个引脚折掉的不是一半是接近开根号的比例。这个资源包里除了矩阵键盘扫描相关的标准库文件还带着 stm32f10x_tim.c、stm32f10x_adc.c、stm32f10x_usart.c 这些外设驱动一眼就能看出这是个基于 ST 标准外设库的完整工程骨架。做嵌入式开发的人都知道标准库虽然在慢慢被 HAL 取代但在许多量产或老项目里依然扎实。这篇内容不讲 HAL 的封装直接落到 GPIO 配置、扫描算法、中断响应和去抖这几个核心点适合两类人刚学 STM32 想用手边现有板子快速验证矩阵键盘的初学者以及要在现有工程里嵌入键盘模块、需要理解底层 IO 行为而不只是调库函数的开发者。矩阵扫描的基本原理可以用一句话说透每次只驱动一条线行或列然后读另一组线的电平通过两次读数交叉定位按键位置。听起来简单但真正实现时会遇到浮空输入误触发、按键抖动、行列引脚复用这些实际工程问题都需要在代码层面设计清楚。下面按硬件连接、扫描算法、中断优化、调试验证的顺序拆开讲。2. 硬件连接与 GPIO 配置上拉输入与推挽输出的配合2.1 确定行列引脚与接线在 STM32F103 上做 4x4 矩阵键盘常见的接法是把行线ROW0~ROW3接到 PA0-PA3列线COL0~COL3接到 PB0-PB3。素材里提到 PA0-PA3 做行线、PB0-PB3 做列线这个分配有一定合理性PA0-PA3 同时支持 EXTI0-EXTI3 外部中断如果后面要做按键唤醒或边缘触发不需要换引脚。接线逻辑如下信号STM32F103 引脚方向模式ROW0PA0输入上拉输入或开漏输出ROW1PA1输入上拉输入ROW2PA2输入上拉输入ROW3PA3输入上拉输入COL0PB0输出推挽输出COL1PB1输出推挽输出COL2PB2输出推挽输出COL3PB3输出推挽输出公共端GND--矩阵键盘模块的引脚顺序各厂家并不统一拿到模块后先用万用表蜂鸣档量一下行线、列线之间的导通关系确认模块内部是“行线公用、列线分开”还是反过来。接错行列定义只会导致扫描时读不到键值不会烧板子但会浪费大量排查时间。把行接上拉输入是因为默认情况下行线被拉高当某一列被拉低且该交叉点按键按下时对应行线被拉到低电平读到的状态变化就是按键触发信号。2.2 标准库下的 GPIO 初始化资源包里是标准外设库工程GPIO 初始化走 RCC 时钟使能加 GPIO_Init 的固定流程。下面这段初始化代码覆盖行输入和列输出两种模式void MatrixKey_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; // 使能 GPIOA 和 GPIOB 时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); // 行线 PA0-PA3上拉输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; // 输入模式其实不需要速度但标准库会要求填 GPIO_Init(GPIOA, GPIO_InitStructure); // 列线 PB0-PB3推挽输出初始全部输出高电平 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); // 全部列线置高避免初始状态误触发 GPIO_SetBits(GPIOB, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3); }为什么列线用推挽输出而不是开漏输出推挽输出能主动拉高和拉低扫描时把某一列拉低电平跳变干脆读行线时不容易受到外部干扰。开漏输出需要外部上拉电阻而且拉低能力强、拉高能力弱如果板子上没有对应的上拉电阻释放总线时电平可能是悬空的直接导致误判。行线用上拉输入则是利用 STM32F103 内部约 30-50kΩ 的上拉电阻省掉外部电阻同时保证没有按键按下时行线读到确定的高电平。GPIO_Speed 参数在这里不是必须的但标准库的 GPIO_Init 结构体里不填这个字段会使用不确定值某些库版本会触发断言错误。习惯上统一填 50MHz虽然输入模式下速度设置对数字输入没有实际影响但能避免后续把同一组引脚从输入切到输出时忘记改配置的遗漏。2.3 引脚复用的检查项在开发板上接矩阵键盘之前先确认所选引脚有没有被板载外设占用。最常见的问题是 PB3、PB4 在 STM32F103 上默认复用为 JTAG 引脚PB3 是 JTDO、PB4 是 JNTRST。如果你把列线接到 PB3/PB4直接操作 GPIO 可能无效必须先把 JTAG 完全关闭或切换到 SWD 模式// 禁用 JTAG保留 SWD释放 PB3、PB4 GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);这个函数必须在 GPIO 初始化之前调用而且要重新初始化一次时钟否则配置不生效。如果开发板用的调试器只走 SWD比如常见的 ST-LINK 默认就是 SWD 模式直接禁用 JTAG 不会影响调试还能腾出 PB3/PB4 两个引脚。另外 PA0-PA3 在有些板子上接了按键或 LED比如正点原子的精英板 PA0 是 WakeUp 按键、PA1-PA3 有的接了其他外设拿到板子先看原理图再决定接线比改代码省时间。3. 扫描与按键值定位逐行扫描算法在标准库下的实现3.1 扫描流程与电平变化逻辑逐行扫描的思路是固定行线为输入、列线为输出每次只把一列拉低其余列保持高电平然后读全部行线。假设当前 COL1 为低电平此时如果检测到 ROW2 也为低说明按键位置是 (ROW2, COL1) 交叉点。这个过程相当于用一个低电平探测信号扫过每一列行的电平变化给出了横坐标当前哪一列被拉低给出了纵坐标。反过来做逐列扫描也成立关键是行列中必须有一组是浮空或上拉输入另一组是推挽输出。行做输入、列做输出之所以是推荐方案是因为行线对应的 PA0-PA3 都带外部中断能力后续如果改成外部中断触发扫描不用重新规划引脚。具体时序是这样的先拉低 COL0延时几微秒等待电平稳定读取 PA0-PA3释放 COL0拉低 COL1再读一遍。读完 4 列一轮扫描结束。3.2 核心扫描函数实现#define ROW_PORT GPIOA #define ROW_PINS (GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3) #define COL_PORT GPIOB #define COL0_PIN GPIO_Pin_0 #define COL1_PIN GPIO_Pin_1 #define COL2_PIN GPIO_Pin_2 #define COL3_PIN GPIO_Pin_3 static const uint8_t key_map[4][4] { {0x01, 0x02, 0x03, 0x0A}, // 第 0 行1 2 3 10 {0x04, 0x05, 0x06, 0x0B}, // 第 1 行4 5 6 11 {0x07, 0x08, 0x09, 0x0C}, // 第 2 行7 8 9 12 {0x0E, 0x00, 0x0F, 0x0D} // 第 3 行* 0 # 13 }; uint8_t MatrixKey_Scan(void) { uint8_t row, col; uint16_t col_pins[4] {COL0_PIN, COL1_PIN, COL2_PIN, COL3_PIN}; for (col 0; col 4; col) { // 先全部拉高再单独拉低当前列 GPIO_SetBits(COL_PORT, COL0_PIN | COL1_PIN | COL2_PIN | COL3_PIN); GPIO_ResetBits(COL_PORT, col_pins[col]); // 短延时等待列电平稳定 for (volatile uint32_t i 0; i 10; i); // 读取 4 条行线 uint16_t row_status GPIO_ReadInputData(ROW_PORT) ROW_PINS; // 检查哪一行被拉低 for (row 0; row 4; row) { if ((row_status (1 (row))) 0) { return key_map[row][col]; // 返回映射后的键值 } } } return 0xFF; // 无按键 }这里有几个参数需要把逻辑说明白。GPIO_ReadInputData 读的是整个 GPIOA 端口的状态必须用 ROW_PINS 做掩码这样即使 PA4-PA7 接了其他外设也不会干扰判断。1 row对应的是 PA0-PA3 各位如果你的行线接的是 PA4-PA7这里要改成1 (row 4)否则永远读不对。key_map 数组的行列顺序必须和扫描顺序一致这里用十六进制表示键值实际项目中可以根据按键功能定义枚举或者 ASCII 码。3.3 扫描结果的去抖处理直接调用 MatrixKey_Scan 会有一个明显问题机械按键按下和释放时簧片接触会产生 5-20ms 的不稳定抖动扫描函数可能在同一轮里读到 0x01、0xFF、0x01 交替出现。简单粗暴的延时消抖是这样检测到有键按下后延时 20ms 再读一次两次结果一致才确认。uint8_t MatrixKey_GetKey(void) { static uint8_t last_key 0xFF; uint8_t current_key MatrixKey_Scan(); uint8_t raw_key current_key; // 第一次读到键值后延时再读一次 if (current_key ! 0xFF) { // 简单阻塞延时也可以用定时器实现非阻塞 DelayMs(20); current_key MatrixKey_Scan(); // 两次扫描结果相同才算有效按键 if (current_key ! raw_key) { return 0xFF; } // 检测到不同按键按下返回新键值 if (current_key ! last_key) { last_key current_key; return current_key; } } else { last_key 0xFF; // 按键释放复位记录 } return 0xFF; // 无新按键事件 }这段消抖虽然能工作但阻塞式 DelayMs 会卡住整个主循环。假如系统里同时要处理 OLED 刷新、串口收发和传感器采样一次 20ms 的阻塞就可能导致串口帧超时。更合理的做法是把消抖放进定时器中断或者用状态机方式处理这个放到第 4 章讲。在资源包里看到 stm32f10x_tim.c说明定时器驱动已经在工程里直接把延时替换成定时器计时就能避免阻塞问题。3.4 行列扫描的边界情况矩阵键盘扫描有一个容易被忽略的情况同时按下多个按键会出现串键。比如按下 (ROW0, COL0) 和 (ROW1, COL1)同时拉低 COL0 和 COL1假设代码改成双列扫描ROW0 和 ROW1 都会读到低电平此时系统无法区分是这两个键还是 (ROW0, COL1) 和 (ROW1, COL0) 被按下。处理方式有两种一是扫描时强制每次只拉低一列并只返回第一个检测到的键本质上不响应多键二是做完整的多键状态矩阵每次扫描记录 4x4 的全部状态交给上层做组合键判断。普通键盘输入场景用前一种即可游戏中需要组合键才用后一种。还有一个边界情况是按键释放的检测。上面的 GetKey 函数在按键持续按住时不会重复返回键值只有首次按下才返回。如果你需要做长按、连发功能就需要记录按键按下的时间戳在定时器里累加判断。资源包里带的 stm32f10x_tim.c 正好提供了定时器基础连发功能可以直接挂到定时器中断里实现。4. 消抖与中断驱动把轮询改成事件触发4.1 为什么轮询不适合复杂场景第 3 章的扫描函数在主循环里不断运行CPU 占用率不高但存在两个问题一是按键响应延迟取决于主循环周期如果主循环里有一次耗时操作比如擦写 Flash 或者 I2C 读传感器按键可能要几十毫秒才能被响应二是消抖用的延时函数如果写成阻塞式会反过来拖慢主循环。素材里提到可以用中断来提高响应速度做法是把行线配置为外部中断输入当某一行的电平从高跳到低按键按下时触发 EXTI 中断在中断里执行扫描这样既保证了实时性又让主循环不必密集轮询。不过这里有一个细节矩阵键盘的行线中断只能告诉你“某一列有按键按下”并不能直接告诉你坐标中断触发后仍然要跑一遍扫描流程来定位按键。4.2 行线外部中断配置在标准库下配置 PA0-PA3 的 EXTI 中断需要做几件事开启 AFIO 时钟、配置 EXTI 线和 NVIC、编写中断服务函数。完整代码如下void MatrixKey_EXTI_Init(void) { EXTI_InitTypeDef EXTI_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 开启 AFIO 时钟EXTI 线选择需要 RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); // 把 PA0-PA3 连接到 EXTI0-EXTI3 GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0); GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource1); GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource2); GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource3); // 配置 EXTI0-EXTI3下降沿触发按下时行电平从高到低 EXTI_InitStructure.EXTI_Line EXTI_Line0 | EXTI_Line1 | EXTI_Line2 | EXTI_Line3; EXTI_InitStructure.EXTI_Mode EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger EXTI_Trigger_Falling; EXTI_InitStructure.EXTI_LineCmd ENABLE; EXTI_Init(EXTI_InitStructure); // 设置 NVIC 抢占优先级为 1子优先级为 1 NVIC_InitStructure.NVIC_IRQChannel EXTI0_IRQn; // EXTI0 中断 NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); // EXTI1、EXTI2、EXTI3 的中断也要使能 NVIC_InitStructure.NVIC_IRQChannel EXTI1_IRQn; NVIC_Init(NVIC_InitStructure); NVIC_InitStructure.NVIC_IRQChannel EXTI2_IRQn; NVIC_Init(NVIC_InitStructure); NVIC_InitStructure.NVIC_IRQChannel EXTI3_IRQn; NVIC_Init(NVIC_InitStructure); }用下降沿触发是因为列线默认输出高当某一列被拉低且按键按下时对应行线从高被拉到低。这里要注意如果列线全部保持高电平哪一列都没有被拉低按下按键时行线电位不会变化中断根本不会触发。所以在启用中断之前必须保证至少有一列处于低电平探测状态通常做法是在主循环中让列线保持间隔扫描但这又回到了轮询。更实用的方案是平时所有列线都输出高按键按下时行线不会被拉低所以中断无法触发。因此 STM32 标准库环境下单纯靠行线外部中断做矩阵键盘唤醒并不现实需要改用“列线逐列拉低 行线中断”的混合方式这会增加代码复杂度。4.3 实际可行的中断方案定时器驱动的行列扫描考虑到上面的约束更稳的做法是不用 EXTI而是用定时器中断来周期扫描。STM32 的定时器中断频率稳定我们可以在中断里执行去抖逻辑主循环只负责消费按键事件。这种事件驱动方式同样能解决响应延迟问题而且代码更可控。volatile uint8_t key_event 0xFF; // 全局按键事件标志 volatile uint8_t key_tick 0; // 消抖计数 void TIM2_IRQHandler(void) { static uint8_t last_raw_key 0xFF; static uint8_t stable_key 0xFF; if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); uint8_t raw MatrixKey_Scan(); if (raw 0xFF) { key_tick 0; last_raw_key 0xFF; } else { key_tick; if (key_tick 2) // 连续两次扫描都读到同一个键值 { if (last_raw_key ! raw) // 新按键出现 { last_raw_key raw; key_event raw; // 置事件标志 } key_tick 0; } } } }定时器中断每 5ms 进入一次设置定时器分频和重载值让中断频率约 200Hz。key_tick 2意味着按键状态至少稳定了 10ms这个数字可以根据按键手感微调——手感偏软的按键需要增加到 3-4 次采样手感清脆的按键可以减少到 1 次。这个方案避开了 EXTI 无法检测全部按键的尴尬而且主循环里只需要轮询 key_event 变量while (1) { if (key_event ! 0xFF) { uint8_t key key_event; key_event 0xFF; // 消费事件 // 这里处理按下的键比如串口发送或者修改 LCD 显示 USART_SendData(USART1, key); } }这样按键按下到事件被消费之间的延迟上限是 5ms定时器中断周期比主循环轮询快得多而且主循环里做耗时操作也不会卡按键。4.4 定时器中断与消抖的时序配合一个容易踩的坑是定时器中断里调用 MatrixKey_Scan而 MatrixKey_Scan 里有 GPIO_SetBits / GPIO_ResetBits 操作如果主循环同时也在操作同一个 GPIOB 端口就可能产生竞争。比如主循环正在操作 PB4 控制一个 LED中断突然把 PB0 拉低、PB1-PB3 拉高读改写操作会互相覆盖。解决办法是不在主循环中直接操作 GPIOB 的列线所有列线控制都收归扫描函数如果必须操作 PB 口其他引脚用原子操作的 BSRR/BRR 寄存器代替 ODR#define COL_ALL_HIGH (GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3) // 用 BSRR 置位比 GPIO_SetBits 更安全不影响其他位 GPIOB-BSRR COL_ALL_HIGH; // 用 BRR 复位当前列 GPIOB-BRR (uint16_t)(1 current_col);BSRR 写 1 对应位置位BRR 写 1 对应位清零这两个寄存器是独立的写入不存在读-改-写竞争。这在和高优先级中断共存时能减少踩坑概率。5. 调试技巧串口键值打印与按键冲突排查5.1 用串口输出键值验证扫描矩阵键盘最常遇到的问题是扫描函数返回的值和实际按键对不上。接口层的问题行列接反、引脚复用和无按键时读到 0x00 的浮空干扰都会让整个键值映射错乱。我习惯在写业务逻辑之前先写一个单独的调试入口把扫描返回的原始行状态和列索引打印出来而不是直接打印映射后的键值。这样可以直观看出扫描时序是否正常。void MatrixKey_Debug(void) { uint8_t row, col; uint16_t col_pins[4] {GPIO_Pin_0, GPIO_Pin_1, GPIO_Pin_2, GPIO_Pin_3}; for (col 0; col 4; col) { GPIO_SetBits(GPIOB, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3); GPIO_ResetBits(GPIOB, col_pins[col]); for (volatile uint32_t i 0; i 10; i); uint16_t row_status GPIO_ReadInputData(GPIOA) 0x000F; printf(C%d: row_status0x%04X\r\n, col, row_status); } }串口输出的 row_status 里bit0 对应 PA0、bit1 对应 PA1。正常状态下 row_status 全是 10x000F按下某一行的按键时对应的位变成 0。比如按下第一行第二列的键扫描 COL1 时读到 row_status0x000E说明 PA0 被拉低按键坐标就是 (row0, col1)。如果按下按键时没有任何位变化检查按键模块的公共端接没接 GND部分市售矩阵键盘模块需要外部拉一个公共端到地才能形成回路。5.2 引脚被复用的排查清单遇到按键完全没反应时按下面顺序排查现象可能原因验证方法所有键无反应GPIO 时钟未使能检查 RCC_APB2PeriphClockCmd 是否覆盖 GPIOA/GPIOBPB3/PB4 的行列无效JTAG 复用占用调用 GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE)某一行始终读不到低电平行线上拉未启用或按键模块供电不稳用万用表量行线对地电压按键按下时应接近 0V松开按键后仍有键值反复输出消抖采样次数不足将定时器中断里的 key_tick 阈值从 2 增加到 4按下 A 键却输出 B 键key_map 映射数组与硬件接线不一致在调试函数里打印原始坐标对照 layout 图修正映射资源包里带了 stm32f10x_usart.c串口调试的驱动可以直接用。如果不想用 printf 重定向直接用 USART_SendData 发送十六进制字节也能达到调试目的。关键是原始数据比映射数据更有价值——你可以在确认硬件回路之后再决定键值含义不要让接线错误和代码逻辑错误叠加在一起。5.3 把列线驱动的毫秒级细节优化到位一个容易被忽略但影响体验的细节是列线拉低后的等待时间不能太短也不能太长。太短小于 2us可能导致 GPIO 驱动能力不足时电平还没稳定行线读到旧状态太长大于 100us则每次按键扫描耗时过长如果再加上 20ms 消抖整体响应会显得迟钝。在 72MHz 系统时钟下10 次空循环约等于 8-10us对普通按键扫描足够。// 延时函数约 10us 72MHz static void scan_delay(void) { volatile uint32_t i; for (i 0; i 20; i) { __NOP(); } }如果你将矩阵键盘用到需要低功耗的场景比如电池供电的遥控器可以考虑轮询周期拉长加上 EXTI 唤醒组合的方式平时所有列线输出高行线配置为 EXTI 下降沿触发并进入 STOP 模式。但要注意 NOT 的问题——前面说过列线全高时按下按键行线根本不会被拉低这个方案需要“一根额外中断线”把所有按键并起来接到一个公共中断引脚上。简单做法是给每个按键串联一个二极管把行线信号通过线或逻辑合并到 PA0但这属于硬件改版普通开发板上并不适用。所以在标准开发板上定时器扫描 事件标志是最平衡的方案。5.4 按键事件队列在主循环中的应用最后给一个可直接套用的经验不要把 key_event 直接当成函数返回值在多个模块间传递而是维护一个环形队列。定时器中断只负责往队列里写主循环从队列里取这样即使某个模块处理按键耗时较长也不丢按键事件。#define KEY_QUEUE_SIZE 8 volatile uint8_t key_queue[KEY_QUEUE_SIZE]; volatile uint8_t key_queue_head 0; volatile uint8_t key_queue_tail 0; void KeyQueue_Push(uint8_t key) { uint8_t next (key_queue_head 1) % KEY_QUEUE_SIZE; if (next ! key_queue_tail) // 队列未满 { key_queue[key_queue_head] key; key_queue_head next; } } uint8_t KeyQueue_Pop(void) { uint8_t key 0xFF; if (key_queue_head ! key_queue_tail) // 队列非空 { key key_queue[key_queue_tail]; key_queue_tail (key_queue_tail 1) % KEY_QUEUE_SIZE; } return key; }队列大小的选择取决于你的按键处理耗时一般 8 个足够。在中断里只做入队不做复杂运算这样即便用户在 1 秒内狂按 20 次按键主循环也能保证每个键值被正确处理不会因为一次串口阻塞或 Flash 写入丢键。这套结构对项目后续要加的长按连发、组合键功能都是现成底座不会因为按键事件处理方式而返工。本文还有配套的精品资源点击获取
