资讯动态

STM32步进电机控制:定时器PWM、梯形加减速与限位回零

发布时间:2026/10/26 19:57:10 来源:尧图企业网站定制
简介这份工程资料围绕STM32微控制器驱动步进电机展开面向具备单片机基础、正在做嵌入式课程设计或小型运动控制项目的开发者。内容从GPIO与步进电机驱动器的接口设计讲起覆盖四相绕组控制、全步进/半步进/微步进序列、双按键正反转中断响应以及用定时器调节步进频率实现调速的思路并提示电机过热、过载保护等工程注意事项。资源包共156个文件以38个h头文件、37个c源文件为主体配合工程配置、编译中间文件与可烧录hex等压缩后约4.16MB可直接导入Keil环境对照阅读。已有3505人学习下载适合作为多电机同步、精确定位与速度控制的入门底稿。1. 从一台走不直的滑台说起stm32 控制步进电机在控什么去年调一台两轴滑台样机机械装完以为半天能收工结果平台走 100 mm 实测差 0.8 mm速度一快电机就闷响。查了两天才发现不是机械问题脉冲频率给得太高、没有加减速、细分拨码和代码里假设的对不上。后来换成 16 细分、补上梯形曲线、把脉冲计数改到硬件定时器上重复定位才稳定在 0.02 mm 以内。这套 stm32 控制步进电机的工程要解决的就是这件事用通用定时器输出脉冲PUL 方向脚DIR 使能脚ENA配合限位开关回零和串口指令把一个能转的电机变成能走到指定位置、能停在指定位置的执行机构。它适合刚接手运动控制、手上只有一块 F103 最小系统板的新手照着跑通也适合想把单轴点动升级成可编排运动接口的熟手拆结构。下面按硬件接线、脉冲发生、加减速与回零、踩坑记录、二次开发的顺序讲。2. 硬件连接与工程骨架从驱动器拨码到目录分层拿到工程先别急着编译下载先确认硬件链路和代码里的引脚假设是一致的。这一层错了后面所有调试都是玄学。2.1 三个控制信号的实际职责步进驱动器对外一般就三组线PUL脉冲、DIR方向、ENA使能加上电机四线和电源。信号方向是 MCU 输出、驱动器输入所以全部配成推挽输出如果 PUL 走定时器通道就配成复用推挽。信号接法电平含义常见坑PUL定时器 PWM 通道每个上升沿走一个细分步用 GPIO 翻转模拟脉冲高速时频率上不去DIR普通 GPIO高/低对应正/反具体看驱动器手册换向时不延时驱动器来不及识别ENA普通 GPIO多数模块低电平使能少数相反极性搞反代码在跑但电机被锁死关于 ENA有的驱动器模块标注EN且默认低有效有的是高有效。我一般不让 ENA 常开而是在运动前 5 ms 拉使能、运动结束后延时 200 ms 再释放这样静止时电机不进全流发热会小很多。如果你接的是那种没有 ENA 的两线模块只有 PUL/DIR那就只能靠驱动器自己的半流开关。DIR 换向要留时间。驱动器内部对方向信号有建立时间要求一般手册写的是 5 μs 到 20 μs 不等。实际写法是先改 DIR 电平再延时 20 μs 以上然后才开脉冲定时器。反过来顺序会造成第一次换向丢一步低速时看不出来高速连续换向就会累积误差。2.2 定时器与引脚资源盘点F103 这类芯片上TIM2/TIM3/TIM4/TIM5 都是通用定时器四个通道都能输出 PWM正好一个轴占一个定时器多轴扩展时不用抢资源。推荐选 TIM3 走 PUL因为它的 CH1 默认映射在 PA6不和其他常用外设串口、SWD打架。配置思路是把定时器当可调频率的方波源用系统时钟 72 MHz预分频器设 71计数时钟变成 1 MHz自动重装寄存器 ARR 决定一个脉冲周期有多少个 1 μs比较寄存器 CCR 设成 ARR/2 得到 50% 占空比。这样脉冲频率就是1000000 / ARR代码里换频率只需要改 ARR。// step_motor.h —— 引脚与定时器映射集中在这一层换板子只改这里 #define PUL_TIM TIM3 #define PUL_TIM_CLK RCC_APB1Periph_TIM3 #define PUL_GPIO GPIOA #define PUL_GPIO_CLK RCC_APB2Periph_GPIOA #define PUL_PIN GPIO_Pin_6 // TIM3_CH1 #define DIR_GPIO GPIOB #define DIR_PIN GPIO_Pin_0 #define ENA_GPIO GPIOB #define ENA_PIN GPIO_Pin_1 #define STEP_ANGLE_DEG 1.8f // 两相混合式步进电机整步角 #define MICRO_STEP 16 // 必须与驱动器拨码一致 #define PULSE_PER_REV (uint32_t)(360.0f / STEP_ANGLE_DEG * MICRO_STEP) // 3200PULSE_PER_REV是整个工程里最容易错的一个常量。1.8° 电机整步 200 步一圈16 细分就是 3200 个脉冲一圈。如果驱动器拨的是 8 细分而代码按 16 算走一半距离就停且误差正好一倍这种整齐的错误遇到一次就记住了。// step_motor.c —— PWM 初始化arr 为脉冲周期单位 μs 计数值 void Stepper_Init(uint16_t arr) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tb; TIM_OCInitTypeDef oc; RCC_APB1PeriphClockCmd(PUL_TIM_CLK, ENABLE); RCC_APB2PeriphClockCmd(PUL_GPIO_CLK | RCC_APB2Periph_GPIOB, ENABLE); // PUL复用推挽交给定时器 gpio.GPIO_Pin PUL_PIN; gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(PUL_GPIO, gpio); // DIR / ENA普通推挽。初始化顺序放在定时器之前先把电机摆到安全态 gpio.GPIO_Pin DIR_PIN | ENA_PIN; gpio.GPIO_Mode GPIO_Mode_Out_PP; GPIO_Init(GPIOB, gpio); GPIO_ResetBits(ENA_GPIO, ENA_PIN); // 低有效则先拉高禁用按模块手册调整 tb.TIM_Prescaler 71; // 72MHz / 72 1MHz tb.TIM_Period arr - 1; // 一个脉冲周期的计数值 tb.TIM_ClockDivision TIM_CKD_DIV1; tb.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(PUL_TIM, tb); oc.TIM_OCMode TIM_OCMode_PWM1; oc.TIM_OutputState TIM_OutputState_Enable; oc.TIM_Pulse arr / 2; // 50% 占空比驱动器只认边沿 oc.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(PUL_TIM, oc); TIM_OC1PreloadConfig(PUL_TIM, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(PUL_TIM, ENABLE); // 改 ARR 时下一周期才生效避免撕裂脉冲 }TIM_ARRPreloadConfig这一句不能省。运行中动态改 ARR 做加减速时如果预装载没开新值会立刻生效可能产生一个极窄或极宽的畸形脉冲电机听起来就是咔一下。开了预装载后新周期值在下一个更新事件才装载脉冲宽度始终干净。2.3 工程目录怎么分层裸机工程最容易写成一个大main.c几百行 GPIO 初始化、中断、协议解析堆在一起改一个引脚要全文搜索。我按硬件抽象 → 运动控制 → 业务指令三层拆step_motor_f103/ ├── CMSIS/ 芯片启动与内核头文件 ├── StdPeriph_Driver/ 标准外设库 ├── bsp/ │ ├── bsp_step.c/h PUL/DIR/ENA 引脚与定时器只认脉冲和方向 │ ├── bsp_limit.c/h 限位开关 GPIO 与外部中断 │ └── bsp_uart.c/h 串口收发环形缓冲 ├── motion/ │ ├── motion_axis.c/h 单轴状态机空闲/加速/匀速/减速/回零 │ └── motion_curve.c/h 梯形曲线频率表生成 ├── app/ │ ├── cmd_parser.c/h 串口文本指令解析 │ └── main.c 初始化顺序与主循环 └── user/ └── stm32f10x_conf.h关键约束是bsp_step.c只暴露Stepper_SetDir()、Stepper_SetFreq()、Stepper_Start()、Stepper_Stop()这几个函数不关心走多少步motion_axis.c才知道走 3200 个脉冲这件事。这样后面加第二轴只要再复制一份 bsp 并把定时器换成 TIM4上层逻辑基本不动。提示先把PULSE_PER_REV和驱动器拨码表对一遍再上电。我见过太多走一半停的问题八成是这里差了一倍或几倍。3. 脉冲发生核心定时器 PWM 与步数闭环计数脉冲发得出来只是第一步发得准、停得准才是这个工程的核心。这一章把发脉冲和数脉冲两件事分开讲因为它们的实现方式直接决定了精度上限。3.1 频率与转速的换算关系上位机下发的通常是多少转每分或多少毫米每秒代码里要转成定时器 ARR。换算链路是转速 rpm → 每秒转数 → 每秒脉冲数 → 脉冲周期μs→ ARR。以 3200 脉冲/圈、目标 60 rpm 为例每秒 1 圈即 3200 Hz 脉冲周期 312.5 μsARR 取 312。此时实际频率 3205 Hz误差 0.15%对开环步进完全可以接受。ARR 是整数所以频率越低相对误差越大要更细就把预分频器改小让计数时钟变高。// 输入的 rpm 转成 ARR1MHz 计数时钟下 uint16_t RpmToArr(float rpm) { if (rpm 0.1f) rpm 0.1f; float hz rpm / 60.0f * (float)PULSE_PER_REV; // 每秒脉冲数 if (hz 200000.0f) hz 200000.0f; // 上限保护 uint32_t arr (uint32_t)(1000000.0f / hz); if (arr 20) arr 20; // 下限ARR 太小脉冲太窄驱动器识别不了 if (arr 65535) arr 65535; // 16 位定时器上限 return (uint16_t)arr; }arr 20这个下限不是随便定的。ARR20 对应 50 kHz 脉冲脉宽 10 μs大多数光电隔离驱动器还能跟上再高就该考虑换方案了。反过来 ARR 太大比如 60000约 16 Hz时脉冲太稀电机每一步都听得见哒哒声这时候要检查是不是细分设太低或者转速指令算错了。3.2 中断计步的实现与它的天花板最直观的做法是开定时器更新中断每进一次中断就认为发了一个脉冲累加到目标值就关定时器volatile uint32_t g_target_pul; // 目标脉冲数 volatile uint32_t g_sent_pul; // 已发脉冲数 volatile uint8_t g_done_flag; // 到位标志 void TIM3_IRQHandler(void) { if (TIM_GetITStatus(PUL_TIM, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(PUL_TIM, TIM_IT_Update); if (g_sent_pul g_target_pul) { g_sent_pul; if (g_sent_pul g_target_pul) { TIM_Cmd(PUL_TIM, DISABLE); // 到点立刻停 g_done_flag 1; } } else { TIM_Cmd(PUL_TIM, DISABLE); } } }逻辑很直白但有三个隐含问题。第一关定时器是在更新事件里做的此时 PWM 输出可能停在高电平或低电平中间最后一个脉冲不完整驱动器会少走一步。第二中断频率等于脉冲频率50 kHz 时每 20 μs 进一次中断主循环基本没时间干别的串口收指令都会超时。第三如果有更高优先级中断打断计数会滞后实际多发几个脉冲。前两个问题在小负载、低转速下可以忍第三个问题在加串口、加定时上报之后必然暴露。所以这个版本只适合验证硬件通路不适合当最终方案。3.3 主从定时器把计数交给硬件熟手的做法是用两个定时器一个负责发脉冲主一个负责数脉冲从。主定时器 TIM3 每个周期通过 TRGO 输出一个触发信号从定时器 TIM4 配置成外部时钟模式 1把这个触发当计数时钟ARR 直接设成目标脉冲数 - 1计满产生更新中断并自动停止。void Stepper_MoveByHw(uint32_t pulses, uint16_t arr) { TIM_TimeBaseInitTypeDef tb; // 主TIM3 发脉冲OC1REF 作为 TRGO TIM_SelectOutputTrigger(PUL_TIM, TIM_TRGO_OC1REF); // 从TIM4 外部计数时钟源选 ITR2TIM3 的 TRGO RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); tb.TIM_Prescaler 0; // 不预分频来一个数一个 tb.TIM_Period pulses - 1; // 数到目标值就溢出 tb.TIM_ClockDivision TIM_CKD_DIV1; tb.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM4, tb); TIM_SelectInputTrigger(TIM4, TIM_TS_ITR2); TIM_SelectSlaveMode(TIM4, TIM_SlaveMode_External1); TIM_ClearFlag(TIM4, TIM_FLAG_Update); TIM_ITConfig(TIM4, TIM_IT_Update, ENABLE); TIM_SetCounter(TIM4, 0); TIM_Cmd(TIM4, ENABLE); TIM_Cmd(PUL_TIM, ENABLE); // 最后才开脉冲避免多计 } // TIM4 溢出中断到位处理只做标志位不做耗时操作 void TIM4_IRQHandler(void) { if (TIM_GetITStatus(TIM4, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM4, TIM_IT_Update); TIM_Cmd(PUL_TIM, DISABLE); g_done_flag 1; } }这个方案的好处是计数完全由硬件完成CPU 只在最后到位时进一次中断50 kHz 甚至 200 kHz 脉冲都不会压垮系统。TIM_SelectOutputTrigger选OC1REF而不是TIM_TRGO_Update是因为更新事件在计数器溢出时产生和比较输出的边沿有相位差选 OC1REF 能保证发一个脉冲、计一个数严格一一对应。启用这个方案要注意两点一是 TIM4 的输入触发源编号要和芯片参考手册的复用表对上F103 上 TIM3 对应 TIM4 的 ITR2换 TIM2 做主轴就要改成 ITR1二是从定时器的 ARR 是 16 位单次最多走 65535 个脉冲也就是 16 细分下 20 圈左右走长距离要在上层拆成多段。4. 运动曲线与回零梯形加减速和限位保护电机能按指定步数停下来之后下一个问题是怎么走。直接给最高频率启动轻载能转稍微带点负载就堵转或丢步这不是电机的问题是频率给错了。4.1 为什么必须加减速步进电机的输出扭矩随脉冲频率升高而下降这是它的物理特性。低频时扭矩最大频率越高绕组电流来不及建立扭矩掉得很快。所以从静止直接跳到高速相当于让电机在扭矩最小的状态下克服最大的静摩擦结果就是嗡一声不转或者原地丢步。梯形曲线的做法是把一段运动拆成加速、匀速、减速三段从启动频率开始按设定加速度升到最高频率保持然后对称降回启动频率再停。参数就四个起始频率、最高频率、加速度、总脉冲数。当总脉冲数太少、来不及加到最高速时曲线自动退化成三角形这个判断要在生成曲线前做掉。typedef struct { uint32_t total; // 总脉冲数 uint32_t accel_pul; // 加速段脉冲数 uint32_t decel_pul; // 减速段脉冲数 uint32_t f_start; // 起始频率 Hz uint32_t f_max; // 最高频率 Hz } CurveParam; // 根据当前已发脉冲序号算出应该用的频率 uint32_t Curve_GetFreq(const CurveParam *p, uint32_t idx) { if (idx p-accel_pul) { // 加速段 return p-f_start (uint32_t)((float)(p-f_max - p-f_start) * idx / p-accel_pul); } if (idx p-total - p-decel_pul) { // 减速段 uint32_t k p-total - idx; return p-f_start (uint32_t)((float)(p-f_max - p-f_start) * k / p-decel_pul); } return p-f_max; // 匀速段 }这里是按频率线性插值物理上对应加速度逐渐变小严格做法应该按 1/√n 曲线算周期但对 3D 打印、点胶、简单搬运这类场景线性插值已经够用代码也好读。真要抠平滑度把Curve_GetFreq换成查表就行上层调用不用动。实际下发时不是每个脉冲都改一次 ARR那样中断太密。常见做法是每 16 或 32 个脉冲更新一次 ARR中间保持。更新点放在从定时器的比较中断里做或者在主循环轮询g_sent_pul的倍数关系。我一般选 3216 细分、3200 脉冲/圈的情况下一圈改 100 次曲线肉眼看着已经很顺。4.2 限位与回零的状态机开环系统没有编码器反馈唯一的位置基准就是限位开关。回零流程我固定按这三步走几乎不用改第一步快速向原点方向运动速度取最高速的 30%同时打开限位外部中断。第二步碰到限位立即停脉冲反向低速退出约 2 mm让开关弹片复位。第三步以更慢的速度比如 5 rpm再次逼近二次触发时把软件位置计数器清零这个点就是机械原点。// 限位外部中断只做停车和置标志绝不在这里做延时或打印 void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { EXTI_ClearITPendingBit(EXTI_Line0); TIM_Cmd(PUL_TIM, DISABLE); // 硬件立即停脉冲 TIM_Cmd(TIM4, DISABLE); g_limit_hit 1; // 交给主循环判断是回零还是急停 g_axis_state AXIS_FAULT; } }为什么不在中断里直接调回零流程因为回零里含着反向运动和延时放中断里会把整个系统卡住串口都收不了。中断只负责停车 打标记状态机在主循环里推进这是裸机工程里最省心的分工。限位开关的接线也值得说一句。机械行程两端各一个开关接成常闭NC比常开NO安全线断了相当于触发系统会停在故障态而不是一路撞过去。如果只用了常开至少要在初始化时读一次电平读到已触发就直接进故障别让它带着错误状态开始运动。注意回零过程中把软件位置清零之后一定要同步把g_sent_pul、g_target_pul和从定时器计数器一起清只清一个会出现位置是 0 但定时器还欠着几百个脉冲的错位。5. 避坑与排查五个真实翻车现场这一章按现象 → 原因 → 解决记五条都是调这个工程时真会遇到的问题按顺序排查能省掉大量时间。5.1 电机只抖不转轴有轻微颤动和嗡声现象上电给脉冲电机不旋转只有轴在微小角度上来回抖手摸有震动感驱动器指示灯正常。原因通常有四类。一是电机四线相序接错把同一相的两根线接到了不同相上磁场形不成旋转。二是脉冲频率太高超出该电压下的启动频率电机来不及跟上。三是细分拨码和代码里的MICRO_STEP不一致代码以为走了很多步实际驱动器按另一种细分在响应。四是 ENA 极性反了代码以为使能了实际驱动器处于禁用态绕组没电流只有残余磁场在抖。解决按这个顺序来先用万用表通断档量电机四线相通的两根线电阻一般几欧且成对相等据此分出 A/A-、B/B-再把脉冲频率降到 200 Hz 以下试转能转说明是频率问题然后把 ENA 直接悬空或者按手册反相试一次最后核对驱动器拨码表把MICRO_STEP改成和拨码一致。5.2 低速正常一提速就丢步位置越走越偏现象30 rpm 以下走 10 圈回到原点误差很小提到 120 rpm 后连续走几次位置单向偏移且偏移量随速度升高而增大。原因是没有加减速直接从启动频率跳到高速加速瞬间丢的步数累积成了固定偏差也可能是驱动器电流拨得太小高速时扭矩不足电源电压偏低同样会在大电流时掉压。解决办法是上梯形曲线把加速度控制在电机能跟上的范围内先用 500 rpm/s 试再往上加把驱动器电流拨到电机额定电流的 70% 到 85%别一上来就拧到最大那样发热会很快电源电压留 30% 以上余量24 V 系统驱动 42 步进电机比 12 V 稳得多。5.3 中断里计步走 3200 步实际只走了 3197现象指令走一圈3200 脉冲用标记笔在联轴器上做参考转回来差一点点反复几次误差单调累积。原因有两个一是更新中断里关定时器的时机在脉冲周期中间最后一个脉冲被截断驱动器没识别到那个上升沿二是系统里加了串口接收中断和 SysTick优先级配置不当计步中断被延迟实际多发脉冲而计数没跟上。解决优先换成主从定时器硬件计数从根上避免截断和延迟如果坚持用中断方案把脉冲计数中断设为最高优先级并且在关定时器前先用单脉冲模式TIM_SelectOnePulseMode发完最后一个完整脉冲或者把 CCR 改成占空比更小的形式让关断发生在低电平区间。串口打印这类操作一律挪出中断。5.4 驱动器发热明显静止时电机有高频啸叫现象电机不转但外壳烫手贴近能听到持续的高频尖啸驱动器散热片温度也偏高。原因ENA 常开导致静止时保持电流全开电机和驱动器都在持续发热脉冲频率落在人耳敏感的 2 kHz 到 8 kHz 区间产生可听啸叫细分设得太低每一步的电流跳变更大噪音和震动都更明显。解决加自动使能管理运动结束后延时 200 ms 释放 ENA靠驱动器自身的半流功能维持位置把常用转速对应的脉冲频率错开人耳敏感区或者把细分提到 16 以上让频率抬高按电机额定电流重新拨码不要为了更有劲长期超额定运行。5.5 上电瞬间电机自己动一下或者直接朝反方向冲现象每次复位或上电电机都会有一个小角度抖动偶尔朝一个方向持续走一小段才停。原因GPIO 上电默认是浮空输入DIR 和 ENA 电平不确定驱动器可能在上电瞬间收到一个方向变化或使能跳变定时器使能前 ARR 还是复位值可能产生一个极窄脉冲另外复位时电机残余磁场也会造成一次小位移。解决把 GPIO 初始化放到所有外设最前面先把 ENA 拉到禁用电平、DIR 定死成已知方向再初始化定时器参数最后才TIM_Cmd使能整机上电后加 100 ms 延时等电源和驱动器内部电路稳定再开始接受运动指令如果对位置极敏感加一个上电自检状态只允许手动点动确认方向正确后才放开自动流程。6. 二次开发把单轴点动改成可编排的运动接口工程能跑之后下一步基本都是再来一轴或者接个上位机。改动量最小的路径是先抽接口、再加协议、最后考虑插补。抽接口的做法是定义一层轴对象把定时器、引脚、方向极性、每圈脉冲数都塞进结构体里上层只认轴号typedef struct { uint8_t id; uint8_t dir_level; // 正转对应的 DIR 电平 uint32_t pulse_per_rev; // 该轴实际的脉冲每圈 int32_t pos_pulse; // 软件位置 uint8_t state; // 空闲/运动/回零/故障 } AxisHandle; void Axis_MoveAbs(AxisHandle *ax, int32_t target_pulse, uint32_t f_max); void Axis_MoveRel(AxisHandle *ax, int32_t delta_pulse, uint32_t f_max); void Axis_Home(AxisHandle *ax); void Axis_StopNow(AxisHandle *ax);新增一个轴就是把bsp_step复制一份换定时器然后填一个AxisHandle。上层调用从直接操作 TIM3变成Axis_MoveAbs(axis1, 3200, 2000)可读性和可维护性都不一样了。串口协议建议用最省事的文本行格式AX1 MOVE 3200 2000\r\n一行一条指令回车结束解析用固定字段切分就行不用上复杂的帧头和校验。真要抗干扰再加 CRC但调试阶段文本协议省下来的时间比你想的多。指令解析放在主循环运动执行交给状态机两者之间用一个环形命令队列解耦避免串口收一半就开跑。多轴联动时不要急着上直线插补。两个轴各自按时间同步走完自己的距离这叫同时到达不是直线运动遇到需要斜线轨迹的场合误差会很明显。真要做插补得引入统一的插补周期比如 1 ms 调一次每轴算增量再用 Bresenham 或者 DDA 分配脉冲这就不是改几行代码的事了先确认需求再动手。验证方法我习惯分三层单轴空载走 100 次往返看pos_pulse累计误差带载加 50% 负重再跑一遍看是否丢步最后连续运行 30 分钟摸电机和驱动器温度超过 60 ℃ 就得回头调电流和加减速。这三层过了再上整机联调能砍掉一大半现场问题。说白了这套工程的骨架就是定时器发脉冲、硬件数脉冲、状态机管流程、限位保安全剩下的都是围绕这四点的打磨。后面每次换电机、换驱动器我第一件事都是量相序、对拨码、把启动频率压到最低试转再逐级往上加。那年滑台差 0.8 mm 的教训之后就落成了这个习惯多花二十分钟核对比在现场拆机柜强。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑