news 2026/9/8 22:11:25

给固件“长出”界面:STM32+ESP8266无线PID调试实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
给固件“长出”界面:STM32+ESP8266无线PID调试实战

做控制类项目调试,最消磨耐心的事情往往不是算法本身,而是参数埋在代码里、状态埋在串口日志里、你在电脑和示波器之间来回跑。改一个Kp要重新编译烧录,看实时转速又要另开一个窗口,一套流程走下来,一个晚上真正花在整定上的时间可能不到三分之一。

我做这台单轴速度环实验平台时也掉进过这个坑。固件已经从PID跑通一路做到速度响应还可以,但每次调参都要拔线、烧录、上电、观察,循环往复。直到某天我实在受不了,在正式整定之前狠狠心花了一个周末,先给固件加了一套能看能改的人机交互通道——用大白话说,就是先让固件"长出"界面,再回头去调参数。

这一期就把"界面长出"的过程完整拆开:硬件上怎么用ESP-01S给STM32F103C8T6加一条无线链路,软件上怎么设计一套够用的命令协议,以及最重要的,这套界面在真实整定里能帮你省下多少事。全程基于我的搭建记录,有代码、有接线、有踩坑,适合正在做PID调试、电机控制,或者任何嵌入式参数调试的工程师参考。

1. 调试期的界面,和产品UI是两回事

1.1 先想清楚:整定的时候你到底想看什么、改什么

很多朋友一说加人机界面,第一反应就是上屏幕、上图标、做花哨的仪表盘。但嵌入式调试期需要的界面,本质不是"给人看的UI",而是"给参数和状态开一条通道"。

整定PID时,你真正关心的东西就那么几个:目标值给了多少,实际转速跟到了多少,当前PWM输出有多接近饱和,三个增益参数现在分别是什么。这些信息不需要多漂亮的呈现,但必须做到两点——实时、可改。

实时意味着我不用停下控制回路去看数据,调Kp的时候电机一直在转,反馈能同步跟上;可改意味着我不用为了试一个新参数而重新编译烧录,直接在界面上把数值改掉,下一拍控制周期就能生效。

我的做法是先列了一张需求表,区分"只读"和"可读写",再按调试痛点排序:

项目读写属性优先级为什么需要
目标转速 rpm_set可读写P0整定时改给定值,模拟阶跃响应
实际转速 rpm_act只读P0看反馈是否跟上给定,是判断收敛的核心
速度环 Kp可读写P1最常调的先比例增益
速度环 Ki可读写P1消除稳态误差用的积分增益
速度环 Kd可读写P2抑制超调用的微分增益
PWM占空比 duty只读P1看输出有没有撞限幅,撞了说明参数方向不对
电机使能状态可读写P0一键启停,调参过程中反复用

这张表就是整个界面功能的最小集。我建议你也先做这张表,而不是直接开工写代码。控制参数项的增删、显示项的频率、哪些参数允许在线改,全都由这张表决定。调试期界面最忌讳的是"什么都往上堆",堆到最后反而找不到关键数据。

1.2 排序原则:少量但关键,先可读后可视

有了需求表之后,第二个原则是分阶段交付。第一版界面哪怕只有一个"rpm_set"可填、一个"rpm_act"可看,都好过憋一个大而全的网页最后没做完。

我实际做的时候把P0级别的功能全部放在第一版:目标转速可调、实际转速可见、电机使能开关。P1级别的Kp和Ki放在第二版,Kd和PWM只读放在第一版顺手带上。这个顺序不是随便排的,它对应着整定时的真实操作习惯:先给一个目标值,观察跟随;再动Kp,观察震荡;再动Ki,观察稳态误差。你不需要一口气把所有参数都暴露出来,把P0那几项做到顺手,调试体验就已经脱胎换骨。

另一个细节提醒:调试期界面请放弃"可视化"的执念。我在第二版之前曾经想给网页加上实时曲线,用Canvas画转速波形,折腾了两个晚上。后来发现,整定阶跃响应时我看一眼"目标值"和"实际值"两个数字的差值变化,比盯着一根正弦震荡的曲线更直接。曲线有价值,但那是后期优化的事,不是界面从无到有的第一步。先把文字数值通道做通,可视化的钱后面再花。

2. 界面通道选型:同一条命令,既走串口也走WiFi

2.1 为什么没有选OLED屏幕,也没有选串口屏

做嵌入式人机界面的常规方案无非这么几种:板载OLED+按键、USART HMI串口屏、WiFi模块+网页。我把它们放在一起对比过,最后选了WiFi方案。

方案硬件成本开发量显示信息量交互体验
0.96寸OLED+编码器按键低,约10元中,需要写菜单逻辑少,一屏最多几行改参数要翻页,勉强可用
USART HMI串口屏中高,30元以上低,组态软件拖拽中等,可以画界面成品感强,但调试期性价比低
ESP-01S+手机/电脑网页低,约8元中高,需要写解析多,无限制手机上直接改,非常顺手

OLED方案最大的问题不是显示,而是"改参数要翻页"。想象一下你正在调Ki,每改一次要在菜单里往下翻三层,按编码器确认,这比重新烧录省不了多少时间。串口屏确实界面漂亮,但成本高、还要学一套组态工具,更适合做成产品后的展示,不是调试期的快速迭代工具。

WiFi方案最打动我的一点是零显示成本。ESP-01S模块不到十块钱,手机或者电脑浏览器就是现成的显示屏和键盘。我把电机平台放在工作台上,手机连上模块发起的AP,躺在椅子上就能改参数、看转速,这个调试姿势一旦习惯就回不去了。

2.2 协议与物理通道解耦:串口命令行是地基

选WiFi方案之前,我还做了一个关键决定:命令协议和物理通道完全解耦。也就是说,第一版先实现串口命令行——用USB转TTL线连电脑,在串口助手里输入文本命令操作参数;第二版再把ESP-01S挂到另一个串口上,让同样的文本命令走WiFi,被网页调用。

这个解耦的价值在于:串口命令行的调试能力下限很低,但非常稳定。即使后来WiFi出问题,我随时可以用一条USB线回到文本交互模式,整个固件的参数读写功能不受影响。而网页界面只是一个把文本命令包了一层HTTP外壳的"远程串口终端"。

所以下面这套协议才是本期的核心资产。不管你是用串口、WiFi、蓝牙还是USB CDC,套上任何传输通道,这套参数读写逻辑都能跑。它让"改参数"变成固件里一个通用动作,而不是为每个功能写一堆特判。

2.3 ESP-01S的硬件准备:接线与供电的坑

先给ESP-01S做好硬件准备。模块工作路径确定:STM32的USART1接ESP-01S,STM32的USART2接调试串口(USB转TTL)。接线表如下:

ESP-01S引脚接到哪里
VCC3.3V(单独供电,不能直接裸接板载LDO)
GND与STM32共地
CH_PD(EN)3.3V,拉高使能
GPIO0悬空或高电平,进入运行模式
RXDSTM32的TX(PA9)
TXDSTM32的RX(PA10)

这里有个非常容易踩的坑:ESP-01S发射瞬间电流可以冲到200~300mA,如果STM32板上的3.3V LDO同时还要给其他外设供电,很容易被拉低导致模块不断复位。我一开始直接用的板载3.3V,结果WiFi一开就重启,查了半天。解决办法是给ESP-01S单独用一个小LDO(比如AMS1117-3.3)供电,并且靠模块这边加一个100uF电解电容储能。接线完成后,用AT指令验证一下模块是否正常:上电串口应该回"ready",发送"AT"回"OK"。

ESP-01S默认固件是AT固件,我们需要的就是AT固件,不需要重刷。注意别拿错成其他固件版本,AT指令解析行为差异会让人抓狂。模块配置我设置成AP模式,名字叫"servo_debug",供手机直连:

AT+CWMODE=2 AT+CWSAP="servo_debug","12345678",11,3 AT+CIPMUX=1 AT+CIPSERVER=1,8080

这段AT命令的意思是:模块工作在AP热点模式,开启多路TCP Server并监听8080端口。手机连上这个热点后,浏览器访问192.168.4.1:8080,就能向STM32发起HTTP请求,STM32在串口端解析出请求内容并回应。实际观察发现,浏览器发来的HTTP头很长,STM32端不需要完整解析HTTP协议,只需要从请求行里取出URL中的查询字符串就够了。

3. 一套字段式命令协议:把"改参数"变成一个通用动作

3.1 协议的形态:等号赋值、问号查询、枚举命令

命令协议我设计成最简单的文本行,以回车换行结尾。三条规则:

SET形式:kp=12.5 表示把参数kp设置为12.5 GET形式:kp? 表示查询参数kp当前的数值 动作形式:enable=1 表示执行"使能"这类非数值动作

每条命令执行后都回一个文本帧,要么是当前值,要么是OK/ERR。例如:

收到:kp=12.5 回帧:kp=12.50 OK 收到:rpm_act? 回帧:rpm_act=1530.25 收到:enable=1 回帧:enable=1 OK

这套协议的核心思路是:参数名 + 操作符 + 数值。之所以用文本而不是二进制,是因为调试的时候人可以随手在串口助手里敲,网页里也可以直接用字符串拼接。成本是多几十个字节的带宽,但对115200波特率来说完全无所谓。

实体上,我把参数名映射做成一张表,表里每一项都记录参数名、变量指针、取值范围。这样解析命令的时候,核心逻辑就是一个查表循环,不需要为每个参数复制粘贴一堆if-else。

3.2 参数表驱动解析:一份表解决所有读写

代码结构长这样:

typedef struct { const char *name; // 参数名,例如 "kp" float *var; // 指向目标变量的指针 float min_val; // 允许的最小值 float max_val; // 允许的最大值 } param_entry_t; // 控制相关变量 static volatile float pid_kp = 0.3f; static volatile float pid_ki = 0.01f; static volatile float pid_kd = 0.0f; static volatile float ctrl_ref_rpm = 300.0f; static volatile float ctrl_act_rpm = 0.0f; static volatile float ctrl_duty = 0.0f; static volatile uint8_t ctrl_enable = 0; static const param_entry_t param_table[] = { {"kp", &pid_kp, 0.0f, 100.0f}, {"ki", &pid_ki, 0.0f, 100.0f}, {"kd", &pid_kd, 0.0f, 100.0f}, {"rpm_set", &ctrl_ref_rpm, -3000.0f, 3000.0f}, {"rpm_act", &ctrl_act_rpm, -3000.0f, 3000.0f}, {"duty", &ctrl_duty, 0.0f, 100.0f}, {"enable", &ctrl_enable, 0.0f, 1.0f}, }; #define PARAM_COUNT (sizeof(param_table) / sizeof(param_table[0]))

解析函数的核心就是:把收到的字符串按'='或'?'拆开,取出参数名,查表找到对应项,再根据操作符做赋值或读取。

int param_handle_line(char *line) { char name[16]; char *eq = strchr(line, '='); char *qm = strchr(line, '?'); if (qm != NULL) { // GET 查询 snprintf(name, sizeof(name), "%.*s", (int)(qm - line), line); for (int i = 0; i < PARAM_COUNT; i++) { if (strcmp(name, param_table[i].name) == 0) { send_response("%s=%.2f OK\r\n", param_table[i].name, *param_table[i].var); return 0; } } send_response("ERR UNKNOWN\r\n"); return -1; } if (eq != NULL) { // SET 赋值 snprintf(name, sizeof(name), "%.*s", (int)(eq - line), line); float val = atof(eq + 1); for (int i = 0; i < PARAM_COUNT; i++) { if (strcmp(name, param_table[i].name) == 0) { if (val < param_table[i].min_val || val > param_table[i].max_val) { send_response("ERR RANGE\r\n"); return -1; } param_set_float(param_table[i].var, val); send_response("%s=%.2f OK\r\n", name, val); return 0; } } send_response("ERR UNKNOWN\r\n"); return -1; } send_response("ERR FORMAT\r\n"); return -1; }

这套设计的优势在于,以后新增一个可调参数,只需要改一下param_table,把变量指针挂进去,对外命令就自动支持了。不需要在解析函数里加代码,也不会出现"新增参数忘了写解析分支"的低级错误。整定后期我给平台加了一个速度前馈系数,就只是往表里加了一行,前后不到一分钟。

3.3 网页端的包装:HTTP请求转成命令行

ESP-01S把浏览器的HTTP请求发到STM32串口时,典型的数据帧长这样:

+IPD,0,185:GET /?cmd=rpm_act? HTTP/1.1 Host: 192.168.4.1:8080

STM32端不需要完整解析HTTP头,只需要在串口接收中将完整数据按'\n'切成一帧,然后查找"+IPD"字段,从"cmd="后面截取命令内容。我封装了一个esp8266_handle_rx()函数专门干这件事,把URL解码后的命令文本取出来直接喂给param_handle_line(),再把回帧包装成HTTP响应发回去。

HTML页面我写得很朴素:一个输入框、一个按钮、一个状态显示区。页面上的JavaScript每200ms向同一地址发起一个rpm_act?请求,把返回值更新到页面上。这样即便页面不做任何花哨效果,动态刷新也已经让"改参数看响应"变得非常顺手。

4. 安全地和中断里的控制算法共享参数:数据一致性容易翻车

4.1 中断里读到的参数可能是"半个新值"

参数表可以读写之后,紧接着就遇到一个更关键的问题:这些参数正在被控制中断使用。

我的速度环在定时器中断里运行,中断频率1kHz,每毫秒读取一次pid_kppid_kictrl_ref_rpm参与计算。如果在中断之外的代码里给pid_kp赋值,而赋值过程被中断打断——尤其是float类型在Cortex-M3上是4字节,可能分多次访问——中断里就可能读到"前两个字节是新值、后两个字节是旧值"的混合状态。这个混合值没有任何物理意义,轻则控制输出跳一下,重则引起系统振荡甚至过流。

这不是理论问题。我第一次把在线改参数接进速度环时,改Kp到一半整个电机猛地抖了一下,就是因为中断在参数赋值过程中间抢进去算了一拍。

4.2 无锁方案:临界区保护赋值

低成本MCU上的参数共享,不需要上RTOS信号量,最简单的做法是赋值时短暂关中断,保证参数写入是原子的:

void param_set_float(volatile float *var, float val) { __disable_irq(); // 关中断 *var = val; __enable_irq(); // 开中断 }

这个函数整体执行时间大约十几个时钟周期,对1kHz控制中断来说完全没有影响。关中断的目的不是不让中断运行,而是让整个4字节赋值在中断看来是一个不可分割的操作。效果等价于单核上的"临界区"。

有些教程会推荐用memcpy来给float赋值,因为编译器可能优化成LDR/STR 32位指令,但这属于"看起来对但没保证"的做法。__disable_irq()+ 直接赋值是简单且可靠的办法。在ARM Cortex-M3上,关中断期间如果外部有紧急事件,也只是延迟十几个周期,这个延迟对速度环控制来说可以忽略。

还要注意一个问题:读取侧。控制中断里读pid_kp等参数时,理论上也存在被主循环写操作的另一个线程破坏的可能,但因为我们写操作已经用关中断保护了,读侧不需要再处理。只要保证写侧原子,读侧在中断里就是安全的。

4.3 实时状态帧上传:怎么发才不拖垮控制回路

参数一致性解决之后,剩下的是状态上传。整定时我需要在网页上看到实际转速,于是每50ms往ESP-01S串口推一帧状态数据:

S 300.00 1530.25 45.3 0.012

字段含义固定:标识符、目标转速、实际转速、PWM占空比、当前误差。网页端每200ms拉一次rpm_act?其实也够用,但把状态做成一整行推送更省事,网页解析也简单。

这里有一个重要的工程约束:USTART发送状态帧的代码不要放在控制中断里。50ms一帧看着不频繁,但如果你直接在中断里调用printf或者字符串格式化,光浮点转字符串的开销就可能让1kHz控制周期产生抖动。我的做法是:控制中断只更新共享变量,主循环里检查一个时间标志,到点后统一格式化并发送。这样即使WiFi通道暂时堵塞,也只是状态帧丢弃重传,绝不影响控制中断的实时性。

5. 参数掉电保存:在Flash里留一块"调试参数区"

5.1 为什么调试期也要掉电保存

在线改参数很方便,但如果每次断电参数就回到代码里的默认值,等于每次重新上电都要把Kp、Ki重新敲一遍。如果只调一次Kp当然无所谓,问题是整定是一个反复迭代的过程,今天调出的一组参数很可能明天还要微调,更常见的是你想对比"昨天那组参数"和"今天这组参数"的差异。

所以我给参数加了一层Flash掉电保存。界面新增两个命令:

save 把当前参数表写入Flash load 从Flash加载参数到RAM

默认上电时执行一次load,如果发现Flash里的参数区无效(比如第一次烧录),就使用代码编译时写死的默认值。

5.2 STM32F103的Flash操作要点

STM32F103C8T6自带64KB Flash,按页管理,每页1KB。写Flash有几个必须遵守的规则:

  • 写入之前必须先擦除,擦除以页为单位;
  • 擦除后Flash内容全为0xFF,写入只能把1变成0;
  • 写Flash期间不能有任何中断访问Flash,否则会引发硬件错误。

最后一个规则是重点。如果控制中断里不小心访问了Flash地址(通常不会,但中断栈溢出时会),写Flash期间会直接HardFault。我在保存参数的函数里也是全程关中断执行,写完后马上恢复。

参数区我分配了两个页,交叉使用。为什么用两页而不是一页?因为擦除一页需要时间,万一写第二页中途断电,第一页里还有上一份完整的参数可以用来恢复。这是一种最简单的防掉电损坏策略:

#define PARAM_PAGE_0 ((uint32_t)0x0800FC00) // 最后一页 #define PARAM_PAGE_1 ((uint32_t)0x0800F800) // 倒数第二页 typedef struct { uint32_t magic; // 固定为 0xA5A5A5A5 uint32_t crc32; float kp; float ki; float kd; float ref_rpm; uint8_t enable; uint8_t reserved[11]; } param_block_t;

写入流程:先把当前RAM参数构建成一个param_block,算好CRC32,先擦除再写当前使用的页,写完后把"当前使用页"标记切到另一页。上电load时先检查magic和CRC,哪个页有效用哪个页。这个双页方案在调试场景下已经足够可靠,真正常见的坑不是掉电,而是你忘了擦除就写,结果覆盖到固件代码区。

为了防止覆盖代码区,我特意把参数页安排在Flash的末尾。写任何Flash地址之前,都在代码里检查:

if (addr < FLASH_BASE || addr >= FLASH_BASE + FLASH_SIZE) { return -1; } if ((addr & 0x3FF) != 0) { return -1; // 地址必须页对齐 }

5.3 保存时机的选择:手动save比自动save更稳妥

有朋友问为什么不做"参数改变后自动保存"。我用过自动保存,后来关掉了,原因是:整定过程中你往往会疯狂改参数,每组参数都自动写Flash的话,Flash擦写寿命会很快耗尽,而且调参过程中的"试错值"根本没保存价值。手动save只在你确定这组参数有效的时候执行,既省Flash寿命,也防止误操作把坏参数固化进去。

网页端我放了一个"保存参数"按钮,只有确认这组参数跑得不错才点它。上电load后,如果你现场改参数改坏了,还有一条reset命令可以把参数恢复成代码默认值,不要等到断电才发现Flash里的参数救不回来。

6. 整定实测:拿着界面调一轮速度环的真实体验

6.1 从纯手工到界面化的整定流程变化

界面通道全部打通后,我重新做了一轮速度环整定,对比以前的方式差距非常明显。

以前:改Kp=0.5,编译烧录,上电看转速,不行,改Kp=0.8,再烧录一次。一次参数迭代至少三分钟。

现在:手机浏览器打开页面,在输入框里敲"kp=0.5",点发送,电机还在转,转速数字直接在页面上刷新。一次参数迭代十秒钟。整定Kp的花费从半小时缩短到五分钟,而且更关键的是,因为反馈是连续可视的,我能看到参数变化引起响应变化的整个过程,对系统动态特性的理解深了一个台阶。

实际调这轮速度环时,我的流程是这样的:先把Ki清零,Kd清零,Kp从0.1开始慢慢加。每加一次,观察实际转速和目标转速的差值。Kp=0.1时转速离目标差一大截,页面上的误差数字一直是三位数;加到0.3时开始有收敛趋势;加到0.6时系统开始出现明显的周期振荡,页面上的转速数字像钟摆一样来回跳。这时候把Kp退回0.45左右,再一点一点加Ki。Ki加到0.005时稳态误差基本消失,但超调开始变大;加Kd抑制超调,Kd=0.02时响应曲线已经比较干净。整个过程没有任何一次重新编译,唯一用到的硬件工具是手机。

6.2 实测中遇到的几个小问题

界面跑起来之后,问题也随之而来。第一个是ESP-01S的TCP连接稳定性。浏览器每200ms轮询一次,长时间运行后模块偶尔会丢连接,页面卡住不动。我的处理办法是页面JavaScript里加了一个重连机制:请求失败后主动刷新整个页面,让浏览器重新建立TCP连接,STM32端不需要做任何特殊处理。

第二个问题是网页端输入参数后误触发送。有一次我不小心把一个很大的Kp值发了进去,电机当场剧烈抖动,我赶紧按了使能关闭开关。这个经历让我在参数范围检查上加了更严格的限制,并且网页端对P0级别的命令加了二次确认。所以别嫌参数表的min/max字段多余,它在关键时刻是保护电机和电源的底线。

第三个问题是串口命令和WiFi命令同时操作同一个参数时的冲突。比如串口端有人在发kp=0.5,网页端又在发kp=0.8,两个请求本身没有问题,因为都走同一个param_handle_line(),函数天然是串行处理的。但如果两边的状态帧格式不一致,显示上会混乱。我最后让WiFi通道和串口通道使用完全相同的回帧格式,网页端JavaScript解析回帧的代码和串口助手查看的文本完全一致,排查问题时心理负担小很多。

6.3 这套界面的后续扩展方向

整定完一轮速度环后,这套人机界面的价值已经充分体现。后续如果要继续进化,我计划往三个方向走:

一是给网页加上实时曲线。轮询模式已经能拿到转速数据,把它放进一个小Canvas画出来,阶跃响应和超调量就能直观看到。这个优化不影响协议设计,因为数据已经以文本形式上传了。

二是做参数组切换。给Flash参数区增加多组编号,比如group0是空载参数,group1是带载参数,切换命令可以一键恢复某一整套参数。这对比"今天调一组明天调一组"的场景非常有用。

三是把同一套命令协议复用到PC端的Python脚本里。Python通过串口和STM32通信,用pyserial发送同样的文本命令,自动化扫描Kp从0.1到1.0的阶跃响应数据,把整定过程变成批量实验。界面是给人看的,协议是给机器用的,两者分离之后,自动化调试这条路自然就通了。

回到最开始的问题:为什么整定之前要先给固件长出人机界面?因为整定本质上是一个"观察-调整-再观察"的循环,这个循环的反馈速度决定了调试效率的上限。当改参数从分钟级变成秒级,当反馈从拆开串口线盯日志变成手机上刷一下就能看到,整定这件事才真正回归到算法本身。这套界面没有什么高深的技术,参数表驱动、文本命令解析、ESP-01S透传,都是很基础的工程手段,但把它们组合在一起,对调试体验的提升是颠覆性的。如果你也正被"反复烧录调参"折磨,不妨先停下来,花一两天给固件长出这套界面,再回头去做整定。

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

MFC x64升级:CListCtrl增强版内嵌编辑框/下拉框/复选框实战

简介&#xff1a;面向 Windows/MFC 开发者的 CXListCtrl 控件增强实现&#xff0c;将编辑框、下拉框、复选框集成到标准列表控件中&#xff0c;并针对 64 位 Visual Studio 2017 做了适配与稳定性修复&#xff0c;适合需要扩展列表交互能力、或学习自定义控件封装思路的中高级 …

作者头像 李华
网站建设 2026/9/8 22:05:10

番茄成熟度检测数据集详解:VOC+YOLO格式目标检测训练实战

简介&#xff1a;番茄成熟度检测数据集面向计算机视觉目标检测与智慧农业应用&#xff0c;提供 277 张番茄图像的完整标注&#xff0c;划分 fully-ripe、semi-ripe、unripe 三个成熟度类别&#xff0c;共 2422 个矩形框&#xff0c;其中未成熟样本最多&#xff08;1593 框&…

作者头像 李华
网站建设 2026/9/8 22:04:50

三分钟素材下载教程:零基础免费存下视频号、抖音、快手资源

三分钟素材下载教程&#xff1a;零基础免费存下视频号、抖音、快手资源 【免费下载链接】res-downloader 视频号、小程序、抖音、快手、小红书、直播流、m3u8、酷狗、QQ音乐等常见网络资源下载! 项目地址: https://gitcode.com/GitHub_Trending/re/res-downloader 你刷到…

作者头像 李华