ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Mixly 自制库开发:从拖积木到造积木的完整实战

Mixly 自制库开发:从拖积木到造积木的完整实战

简介:这套 Mixly 自制库专为 ESP8266 开发设计,聚焦物联网原型制作中最常用的几个环节:EEPROM 断电保存与字符复制、WiFi 自动配网、数据类型转换,以及基于 U8G2 的液晶屏驱动。压缩包共 68 个文件,核心代码是 6 个 JavaScript 模块,配以 60 张 PNG 图解,另有 TXT 说明和 XML 配置,整体 446KB,轻量且容易迁移到不同项目。已有 5786 人学习使用,尤其适合刚接触 ESP8266 的爱好者,以及想在课堂上演示图形化编程的教师。借助说明文档和模块源码,可以弄清如何在 Mixly 中写入/读取 EEPROM 数据、实现设备自动联网,并掌握整数与字符串的相互转换;U8G2 部分则展示了在 OLED/LCD 上绘制文本和简单图形的方法。自制库对模块边界的划分和组织方式,也为读者扩展自己的图形化积木提供了清晰范本。

1. 自制 Mixly 库:从只会拖积木到能造积木的那道坎

第一次从前辈手里拿到一个 mixly库 文件夹时,我还在老老实实拖官方积木。把文件丢进米思齐的库目录重启,模块区凭空多出一排 WiFi 配网、EEPROM 存储的积木,那一刻就意识到:能用积木的人和能造积木的人,中间隔着的不过是一份 XML 描述文件加一堆代码模板。自制库这件事,说白了就是把你已经写好的 Arduino 函数,包装成 Blockly 认识的积木块,让不懂代码的人也能用。这篇笔记会把库结构、配网库实战、数据类型转换和 EEPROM 的坑一次讲完。

2. 自制库的内部结构:XML、代码模板与参数映射的三层关系

2.1 一个库文件夹的解剖:先搞清每个文件是干嘛的

市面上能下载到的 mixly 自制库包,拆开看基本都是同一个骨架。先别急着写代码,把文件认全。以 Mixly 1.x 生态为例,一个完整库文件夹通常长这样:

文件/目录作用是否必须
MyWifi.xml定义积木的外观、参数类型、返回形状必须
_code.txt代码模板,决定积木生成什么 Arduino 语句必须
MyWifi.h/MyWifi.cpp函数实现,真正干活的部分强烈建议
zh-cn.json积木名的中英文映射,部分版本识别可选
images/积木分类的图标可选

注意一个容易踩的坑:Mixly 1.x 和 2.x 对库的定义方式不一样。1.x 用 Blockly 风格的 XML,2.x 改成了 JSON 块描述。从网上下载的旧库放到 2.x 里经常不识别,不是文件坏了,是格式换代了。所以下载任何库之前,先确认对方标的是哪个版本。我目前讲的是 1.x 的主流写法,2.x 的迁移问题放到最后一章说。

文件放哪也很关键。Mixly 启动时会扫描 Arduino 用户目录下的libraries文件夹,找到里面带 XML 的子目录,解析后生成积木分类。放错路径的表现不是报错,而是模块区一片空白,很多新手在这卡半天。正确做法是把整个库文件夹(不是 XML 文件本身)丢进libraries,重启 Mixly。

2.2 block 类型与代码模板:占位符是如何把积木变成代码的

自制库的核心机制就一句话:积木上填的参数,通过占位符拼进代码模板,最终生成 Arduino 代码。先看一个最小的例子。假设我要做一个让 LED 闪一下的积木,XML 里这样描述:

<block type="led_blink_once"> <value name="PIN"> <block type="math_number"> <field name="NUM">13</field> </block> </value> </block>

对应的代码模板_code.txt里是:

pinMode(%1, OUTPUT); digitalWrite(%1, HIGH); delay(300); digitalWrite(%1, LOW);

逻辑说明:type="led_blink_once"是积木的唯一标识,必须有且不能和别的库重复,否则会导致积木覆盖。<value name="PIN">声明了一个参数插槽,嵌套的math_number表示这个插槽只接受数字输入,<field name="NUM">是积木上默认显示的值。MNemonic%1就是第一个参数槽位,Mixly 生成代码时会把用户填的数字替换进去。如果积木有两个参数,模板里用%1、%2依次对应,顺序按 XML 里<value>的排列。

参数说明:%1替换的是用户填的字面值,不是变量名。如果用户填13,生成的就是pinMode(13, OUTPUT);如果用户在积木上改了数字,重新生成代码时%1也会跟着变。这里有一个血泪经验:%1替换后是裸值,字符串类型积木会带引号,数字不会。所以写模板时不要手抖加引号,否则会出现pinMode(13, OUTPUT)变成pinMode("13", OUTPUT)这类编译报错或运行时诡异行为。

2.3 参数类型声明:数字、字符串、布尔与下拉的选用原则

写模板前先想清楚每个参数允许填什么。Mixly 的 XML 通过嵌套 block 类型来约束参数类型,常见对应关系如下:

积木控件XML 嵌套 block 类型生成到模板的内容适用场景
数字输入框math_number裸数字,如115200波特率、引脚号、延时
文本输入框text带引号字符串,如"MyWiFi"SSID、密码、服务器地址
布尔开关logic_booleantrue或false功能开关、高低电平选择
下拉选择自定义field或映射成数字选中项对应值模式枚举、通道选择

这里重点说下拉选择。Mixly 1.x 不同小版本对下拉的实现写法差异很大,有的用dropdown类型,有的要求你自定义一个带options属性的 block 类型。我踩过一轮坑之后,形成了一套保守方案:不用花哨的下拉,直接把枚举映射成math_number,在.h文件里用switch解析。比如配网模式,积木上是一个数字框,填 0 表示 SmartConfig,填 1 表示 SoftAP,代码模板里把这个数字传给函数,函数内部判断。虽然积木不够「傻瓜化」,但换版本时最不容易翻车。

2.4 加载机制:为什么库放对了目录还是看不到积木

Mixly 对自定义库的扫描是有缓存的。第一次启动时解析 XML,之后如果不重启,你改了 XML 它也不会重新读取。更隐蔽的是:如果 XML 文件有语法错误(比如标签未闭合、字段名拼错),Mixly 会静默跳过整个库,模块区不出现任何新积木。我的排查习惯是三步走:先重启 Mixly,再看 XML 有没有被编辑器标红,最后把 XML 里非 ASCII 的中文标签临时改掉,因为 1.x 某些版本对编码敏感。这三步解决了我 90% 的「库不显示」问题。

参数类型的选用直接决定后面类型转换的坑多坑少,所以这块值得花时间想清楚。能用math_number就绝不用text,能限定枚举就绝不放任用户输入,这是自制库参数设计的黄金法则。

3. ESP8266 WiFi 自动配网库:EEPROM 存储与三个积木块的完整实现

3.1 为什么必须做自动配网:从烧死 SSID 到存进 EEPROM

开发 ESP8266 项目时,最烦的就是每次换 WiFi 环境都要改代码重烧固件。固定 SSID 的写法适合演示,不适合产品。自动配网的标准做法是:设备上电先尝试连接上次保存的 WiFi,连不上就进入配网模式,收到手机下发的 WiFi 信息后存入 EEPROM,下次开机直接连。这样就实现了「一次配网,永久记忆」。

方案优点缺点适用场景
固定 SSID 烧死实现最简单换网络必须重烧实验室固定环境
SmartConfig 一键配网用户操作快需要手机 App 支持;阻塞等待有超时坑现场配网、批量部署
SoftAP 配网不依赖 App,浏览器可配开发量大,交互复杂产品化设备

我选择 SmartConfig + EEPROM 组合,原因很实际:ESP8266 官方 SDK 直接支持beginSmartConfig(),代码量最小,配合 EEPROM 缓存后体验接近成品路由器。这次的库设计成三个积木块:

积木块类型生成代码作用
WiFi 自动配网语句块wifi_auto_config(30000)读 EEPROM 尝试连接,失败则进入配网
保存 WiFi 信息语句块wifi_save_config(%1, %2)将 SSID 和密码写入 EEPROM
读取已保存 SSID表达式块wifi_get_ssid()返回字符串,方便 OLED 等显示

3.2 底层实现:EEPROM 读写和 SmartConfig 配网逻辑

先写库的核心头文件MyWifi.h:

#ifndef MYWIFI_H #define MYWIFI_H #include <ESP8266WiFi.h> #include <EEPROM.h> #define WIFI_MAGIC 0xA5 struct WifiConfig { char ssid[32]; char pwd[32]; uint8_t magic; }; extern WifiConfig g_cfg; void wifi_load_config(); void wifi_save_config(const String& ssid, const String& pwd); bool wifi_auto_config(uint32_t timeout_ms); String wifi_get_ssid(); #endif

逻辑说明:结构体WifiConfig用定长char[]而不是String存 SSID 和密码,这是 EEPROM 读写的基本素养——String内部是堆指针,断电重启后指针指向的内存早就不在了,只有char[]才能稳定序列化。magic字段是校验位,写固定值0xA5,读出来如果不是这个值就说明 EEPROM 里没有有效配置,从而避免用垃圾数据去连 WiFi。所有函数声明为extern,方便不同代码模板文件引用,#ifndef防止多个积木重复 include 时重定义。

接下来是MyWifi.cpp的实现:

#include "MyWifi.h" WifiConfig g_cfg; void wifi_load_config() { EEPROM.begin(sizeof(WifiConfig)); EEPROM.get(0, g_cfg); if (g_cfg.magic != WIFI_MAGIC) { memset(&g_cfg, 0, sizeof(g_cfg)); } } void wifi_save_config(const String& ssid, const String& pwd) { memset(&g_cfg, 0, sizeof(g_cfg)); ssid.toCharArray(g_cfg.ssid, sizeof(g_cfg.ssid)); pwd.toCharArray(g_cfg.pwd, sizeof(g_cfg.pwd)); g_cfg.magic = WIFI_MAGIC; EEPROM.begin(sizeof(WifiConfig)); EEPROM.put(0, g_cfg); EEPROM.commit(); } bool wifi_auto_config(uint32_t timeout_ms) { wifi_load_config(); if (g_cfg.magic == WIFI_MAGIC && strlen(g_cfg.ssid) > 0) { WiFi.mode(WIFI_STA); WiFi.begin(g_cfg.ssid, g_cfg.pwd); uint32_t start = millis(); while (WiFi.status() != WL_CONNECTED) { if (millis() - start > timeout_ms) return false; delay(100); } return true; } WiFi.mode(WIFI_STA); WiFi.beginSmartConfig(); uint32_t start = millis(); while (!WiFi.smartConfigDone()) { if (millis() - start > timeout_ms) return false; delay(100); } while (WiFi.status() != WL_CONNECTED) { delay(100); } wifi_save_config(WiFi.SSID(), WiFi.psk()); return true; } String wifi_get_ssid() { wifi_load_config(); return String(g_cfg.ssid); }

逻辑说明:wifi_load_config()每次调用都执行EEPROM.begin(sizeof(WifiConfig)),这是 ESP8266 的规矩。ESP8266 的 EEPROM 库是拿 Flash 模拟的,不调用begin(n)直接get会得到全0xFF。beginSmartConfig()启动配网后,smartConfigDone()返回 true 只代表手机 app 已经把 WiFi 消息发过来了,此时WiFi.SSID()和WiFi.psk()才能取到有效值,所以要再等WL_CONNECTED。

参数说明:timeout_ms默认传 30000 毫秒,这是配网等待的上限。很多库卡死就是因为没这个超时,后面避坑章节专门讲。wifi_save_config接收两个const String&引用,避免拷贝开销,toCharArray时第三个参数传sizeof是复制上限,防止超长密码把结构体后面的字段冲掉造成内存踩踏。

3.3 XML 与代码模板:把三个函数包装成积木块

底层函数写好了,剩下的工作是让这些函数从积木区长出来。新建MyWifi.xml:

<block type="wifi_auto_config"> <value name="TIMEOUT"> <block type="math_number"> <field name="NUM">30000</field> </block> </value> </block> <block type="wifi_save_config"> <value name="SSID"> <block type="text"></block> </value> <value name="PWD"> <block type="text"></block> </value> </block> <block type="wifi_get_ssid"> <value name="RETURN"> <block type="text"></block> </value> </block>

对应_code.txt代码模板:

wifi_auto_config(%1); wifi_save_config(%1, %2); wifi_get_ssid();

逻辑说明:注意第三个积木wifi_get_ssid的类型是表达式块,它嵌套了一个text类型的 value,表示这个积木块最后要返回一个字符串值,可以塞进 OLED 显示或串口打印等其他积木的参数槽里。_code.txt里的三行分别对应三个积木块的生成代码,wifi_get_ssid()没有参数,所以生成的就是一条函数调用。头文件的 include 建议加在第一个积木的模板第一行:#include "MyWifi.h",因为头文件带了#ifndef防重入,多个积木重复 include 不会报错,只是多一次预处理检查。

参数说明:XML 中<block type="wifi_save_config">的type属性必须和_code.txt里生成的函数调用保持一致,否则会出现积木拖出来了、编译却报undefined reference。<value name="SSID">的name属性是积木内部识别槽位用的,改不改不影响生成代码,但建议写成和参数含义相关的名字,方便你三个月后回来维护时看懂。

3.4 在 Mixly 里加载与验证:从模块区到串口输出

把MyWifi.xml、_code.txt、MyWifi.h、MyWifi.cpp放在同一个文件夹,文件夹命名MyWifi,整体丢进 Mixly 的libraries目录,重启 Mixly。验证步骤:

  1. 模块区出现「MyWifi」分类,拖出三个积木到编程区。
  2. 烧录前先打开串口监视器,波特率设 115200。
  3. 上电后观察串口:先打印[WiFi] try saved config...,接着尝试连保存的 WiFi。
  4. 如果之前没保存过配置,设备会进入 SmartConfig 配网状态,此时用支持 ESP-Touch 协议的手机 App 发送家庭 WiFi 信息。
  5. 配网成功后串口打印[WiFi] connected, save config,断电重启后应能看到设备直接连接 WiFi,不再进入配网。

这里有一个很多人忽略的验证点:断电前要确认已经走到wifi_save_config。SmartConfig 收到信息到执行保存之间有个时间差,如果在WiFi.status() != WL_CONNECTED等待循环里就断电,设备会拿不到完整配置,下次开机又回到配网流程。这是硬件调试里典型的「看起来配网成功,实际没存住」问题。

4. 自制库避坑排查:数据类型转换、EEPROM 与编译失败的四个现场

4.1 字符串转数字:看似能编译,一跑就出错

现象:积木上填的波特率9600,生成的代码是Serial.begin("9600"),编译不报错,串口输出全乱码。或者把文本参数转成int后判断出错,比如空字符串转出来是0,导致逻辑误判成「合法」。

原因:text 类型积木生成的是字符串字面量"9600",Arduino 的Serial.begin有long重载,字符串字面量能隐式转换,但转换结果是内存地址而不是数值。更隐蔽的是String("abc").toInt()在解析失败时返回0,这会和真实数值0混淆,没法区分「用户填了 0」和「用户填了非法字符」。

解决:模板里显式转换:

// 错误写法:依赖隐式转换 wifi_send_command(%1); // 正确写法:String 包一层再转,明确意图 wifi_send_command(String(%1).toInt());

逻辑说明:String(%1)先构造临时 String 对象,再调用toInt()把字符串解析成int。这样无论用户填的是不是纯数字,语义都明确。参数说明:如果这个参数本身就是数字框(math_number),不用担心这个问题;只有 text 框才会产生字符串字面量。

4.2 EEPROM 写入静默失败:重启后配置全丢

现象:第一次配网成功,断电重启后又进入配网模式;或者串口打印读出来的 SSID 是\xFF\xFF...一串乱码。

原因:三个高频根因。第一,没调EEPROM.begin(n)直接put,ESP8266 的模拟 EEPROM 不初始化就是全0xFF;第二,begin(n)的 n 小于实际写入的结构体大小,比如结构体 65 字节却begin(64),最后一个字节写越界,commit 时被丢弃;第三,写入后忘记commit(),数据留在缓存没刷进 Flash。结构体里直接放String类型的同学,字符串内容存在堆上,序列化到 Flash 的只是指针的碎片,断电后必丢。

解决:统一用下面的写法:

// 结构体定长、带 magic 校验 struct WifiConfig { char ssid[32]; char pwd[32]; uint8_t magic; }; // 写入前计算真实大小 EEPROM.begin(sizeof(WifiConfig)); EEPROM.put(0, cfg); EEPROM.commit();

逻辑说明:sizeof(WifiConfig)在这里是 65 字节,begin必须传 65 或更大。参数说明:ESP8266 的 EEPROM 库上限是 4096 字节(Flash 扇区大小),超过 4096 的写入会被截断,别想着拿它当大容量存储用,存几百字节的配置信息才是它的本分。依靠 magic 字段判断有效性的好处是:第一次上电 Flash 是干净的0xFF,magic 不等于0xA5,读出来就主动清零,避免一堆乱码 SSID 去连 WiFi 造成长时间假死。

4.3 编译报 undefined reference:block 类型和函数名脱节

现象:积木拖出来了,代码也生成了,编译报undefined reference to 'wifi_auto_config(unsigned int)'。最气人的是函数明明写在.cpp里了,就是链接不上。

原因:函数声明和定义的签名不一致,或者 block type 生成的调用函数名和库文件里的定义不是一个名字。我见过最离谱的一次是 XML 里 block type 写的wifi_auto_config,代码模板写的也是wifi_auto_config,但.h里声明的是bool wifi_auto_config(int timeout),传参类型不同,C++ 符号修饰后就不是同一个函数了。

解决:先看生成代码,再核对签名:

# 把 Mixly 生成的临时 sketch 目录打开,搜函数名 grep -rn "wifi_auto_config" generated_sketch/

逻辑说明:这行命令把生成目录里所有引用wifi_auto_config的地方标出来。重点看声明、调用、定义的参数类型是否一致。参数说明:const String&和String是两个不同的函数签名,C++ 的符号表会对参数类型做名称修饰,一个字符不匹配都会导致链接失败。建议库内部统一用const String&传参,模板生成调用时就不用关心副本开销。

4.4 SmartConfig 卡死:拿不到 WiFi 信息时程序原地等待

现象:设备上电后不执行任何后续逻辑,卡在配网等待;或者配网成功后重启依然进配网模式。

原因:beginSmartConfig()之后,库进入一个等待循环,如果一直没收到手机下发信息,这个循环就是死循环。另外有些版本的 ESP8266 在smartConfigDone()返回 true 后立刻读WiFi.psk()会读到空值,如果不等待WL_CONNECTED就保存,存入 EEPROM 的是空密码。

解决:所有等待循环都加超时,并且保存前确认连接状态:

uint32_t start = millis(); while (!WiFi.smartConfigDone()) { if (millis() - start > timeout_ms) { Serial.println("[WiFi] smartconfig timeout"); return false; } delay(100); }

逻辑说明:timeout_ms由积木参数控制,默认 30000 毫秒,用户自己可以在积木上改。超时后返回false,程序就有机会走其他分支,比如进入 SoftAP 模式或者重启重试,而不是死等。参数说明:delay(100)不能省,没有这个延时,millis()读到的值变化太快,循环空转消耗 CPU 且可能触发看门狗复位。配网完成后的while (WiFi.status() != WL_CONNECTED)同样要带超时,把这一层思考基本就避开大多数配网卡死现场了。

5. 让自制库更耐用的三个进阶习惯

5.1 给库加调试开关,把串口输出留在现场

调试阶段没有串口打印,出了问题只能盲猜。但正式使用时满天飞的Serial.printf又影响性能。我的习惯是在头文件里加一个编译开关:

#define WIFI_DEBUG 1 #if WIFI_DEBUG #define DBG(...) Serial.printf(__VA_ARGS__) #else #define DBG(...) #endif

逻辑说明:WIFI_DEBUG设 1 时 DBG 宏映射到Serial.printf,设 0 时映射为空操作,连参数求值都被优化掉。发布库之前把开关改成 0 即可,不用删代码。参数说明:所有调试输出统一走DBG,不要在代码里裸写Serial.printf,否则后期清理就是一场恶梦。从那以后我每次写库都会先铺好这层宏,省掉无数「这里怎么还有个 print」的尴尬。

5.2 留一个清空配置的隐藏积木

EEPROM 存了错误配置后,调试时想回到配网流程,总不能每次拔 Flash 或者改代码。我在库里额外加了一个wifi_clear_config()函数,专门把 magic 字段改掉:

void wifi_clear_config() { g_cfg.magic = 0; EEPROM.begin(sizeof(WifiConfig)); EEPROM.put(0, g_cfg); EEPROM.commit(); }

逻辑说明:把 magic 写成0,下次wifi_load_config()读出来校验不通过,自动清空结构体,设备重启后就会重新进入配网。这个函数不在正式积木区展示,测试时临时拖出来用,也可以绑一个物理按键「长按 5 秒恢复出厂」,比重新烧固件快一个量级。

5.3 兼容 Mixly 2.x:底层逻辑复用,积木描述重写

迁移到 2.x 时,MyWifi.h和MyWifi.cpp这两个底层文件可以原封不动复用,真正要重写的是 XML 块描述——2.x 改成了 JSON 格式描述积木的槽位、颜色、类型。一次迁移的经验是:把 XML 里的每个<block type>对照着翻译成 JSON 块定义,参数类型、默认值和代码模板的对应关系不变,两个小时就能完成。不要试图用兼容层去骗 2.x 读 1.x 的 XML,这条路我没走通,直接重写反而快。

写库这几年最大的教训来自一次室外验收:设备配网成功、重启后总掉线,折腾一下午,最后发现是EEPROM.begin长度比结构体小 1 字节,magic 校验位被丢在 Flash 扇区外。从那以后,我每次写完库都强制走一遍完整验证流程:清空 EEPROM、冷启动进配网、配网成功后断电、再上电看是否自动连接。这四步走完,库才算真正能交付。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进