前言
M5Paper作为M5Stack家族中唯一配备电子墨水屏的成员,凭借4.7英寸大屏和丰富的扩展接口,如果能够匹配小智AI,将会格外吸睛。
经过一周时间的适配和调试,终于成功的为M5Paper匹配上了小智AI。值得注意的是,M5Paper本身并不带音频编解码器,我在GPIO25/32/26/18/33/34上外接了一块外置音频模块,包含ES8311音频编解码器、NS4150B功放和数字麦克风,实现了完整的音频采集与播放功能。
在实际移植过程中,需要注意准确适用M5Paper的两组I2C总线,不能混淆颠倒!
核心要点包括:M5Unified 库的深度集成、双 I2C 总线的严格隔离,以及电子墨水屏与 LVGL 的适配。
完整源码:https://github.com/HonestQiao/xiaozhi-esp32/tree/m5paper
在 xiaozhi-esp32/main/boards/m5stack-m5paper 目录下,存放了 M5Paper 的所有板级适配代码:
main/boards/m5stack-m5paper/ ├── config.h # 引脚定义与硬件参数(I2C、I2S、EPD 等) ├── config.json # 编译目标声明(ESP32)与 sdkconfig 附加项 ├── m5paper.cc # 核心板级逻辑:Board 类实现、GPIO/I2C/音频/电源初始化 ├── m5paper_display.h # M5Paper 显示屏驱动头文件 ├── m5paper_display.cc # IT8951 电子墨水屏 + LVGL 的具体适配实现 ├── README.md # 编译命令速查 └── sdkconfig.m5paper # PSRAM 预配置模板(编译前必须复制到根目录)
各文件职责说明:
文件作用关键内容
| config.h | 硬件宏定义 | GPIO 引脚分配、EPD 分辨率、音频采样率、EXT_I2C_NUM 等 |
| config.json | 构建配置 | 声明 target: esp32,追加 PSRAM、8MB 分区表等 sdkconfig 项 |
| m5paper.cc | Board 主入口 | 继承 WifiBoard,实现 InitializeI2c()、GetAudioCodec()、GetBatteryLevel() 等 |
| m5paper_display.h/.cc | 显示驱动 | 封装 M5PaperDisplay 类,负责 M5Unified 初始化、LVGL flush、睡眠/唤醒 |
| sdkconfig.m5paper | 内存配置 | CONFIG_SPIRAM=y、CONFIG_SPIRAM_SPEED=40 等关键选项 |
| README.md | 编译指引 | idf.py set-target esp32、cp sdkconfig.m5paper sdkconfig 等命令 |
为了让 M5Paper 的硬件能力被充分利用,本项目引入了以下核心库:
模块作用说明
| M5Unified | 硬件抽象层 | 自动识别 M5Paper,初始化 IT8951 电子墨水屏、AXP192 电源管理、RTC、IMU 等 |
| M5GFX | 图形驱动 | M5Unified 内部依赖,处理 IT8951 的刷屏、灰度转换、pushImage 等 |
关键代码体现:
m5paper.cc 中 #include <M5Unified.h>
m5paper_display.cc 中 #include <esp_lvgl_port.h>
通过 M5.begin(cfg) 一次性完成主板、屏幕、电源、I2C 的自动初始化

M5Paper 存在两组物理独立的 I2C 总线,这是最容易踩坑的地方。代码中必须严格隔离,否则会出现地址冲突或设备初始化失败。
仅用于连接 外接音频模块(ES8311 编解码器)。
SDA:GPIO 25(Port A 黄线)
SCL:GPIO 32(Port A 白线)
设备:ES8311(地址 0x18)
管理方:由 m5paper.cc 中的 InitializeI2c() 手动创建并独占
// m5paper.ccvoid InitializeI2c() {
i2c_master_bus_config_t i2c_bus_cfg = {
.i2c_port = (i2c_port_t)EXT_I2C_NUM, // = I2C_NUM_0
.sda_io_num = AUDIO_CODEC_I2C_SDA_PIN, // GPIO 25
.scl_io_num = AUDIO_CODEC_I2C_SCL_PIN, // GPIO 32
// ...
};
ESP_ERROR_CHECK(i2c_new_master_bus(&i2c_bus_cfg, &i2c_bus_));}由 M5Unified 内部自动初始化,用户无需手动创建。
SDA:GPIO 21
SCL:GPIO 22
设备:GT911 触摸屏、BM8563 RTC、SHT30 温湿度传感器(如外接)等
管理方:通过 M5.In_I2C 访问
// 读取 SHT30 温度(使用 M5Unified 内部 I2C_NUM_1) M5.In_I2C.start(0x44, false, 400000); M5.In_I2C.write(0x2C); M5.In_I2C.write(0x06); M5.In_I2C.stop();
【踩坑记录】:误将 ES8311 配到 I2C_NUM_1
现象:i2c_master_probe 扫描不到 0x18
原因:ES8311 物理上只接到了 GPIO 25/32,对应 I2C_NUM_0
解决:在 config.h 中显式定义 EXT_I2C_NUM = I2C_NUM_0,audio I2C 与 sensor I2C 彻底分离
实际的I2C扫描输出日志:
0 1 2 3 4 5 6 7 8 9 a b c d e f 00: -- -- -- -- -- -- -- -- 10: 18 -- -- -- -- -- -- -- 20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
说明:只有 0x18(ES8311)出现在 I2C_NUM_0 的总线上,证明了总线隔离的正确性。
本项目使用的外接音频模块基于 ES8311 + NS4150B方案,通过 M5Paper 的 Port A/B/C 引出。
/* 引脚定义说明(原子echo风格接线): PORT_A_G25_黄 : SDA -> ES8311 SDA PORT_A_G32_白 : SCL -> ES8311 SCL PORT_B_GND_黑 : MLCK -> NC(未连接) PORT_B_G26_黄 : SCLK -> BCLK PORT_B_G33_白 : DIN -> DOUT(ESP32 DIN 接 ES8311 的 DOUT) PORT_C_G18_黄 : WS -> LRCK PORT_C_G19_白 : DOUT -> DIN(ESP32 DOUT 接 ES8311 的 DIN) PORT_B_5V_红 : 5V -> 模块供电 PORT_B_GND_黑 : GND -> 模块地 */ #define AUDIO_I2S_GPIO_MCLK GPIO_NUM_NC #define AUDIO_I2S_GPIO_BCLK GPIO_NUM_26 #define AUDIO_I2S_GPIO_DIN GPIO_NUM_33 // 从 ES8311 接收音频数据 #define AUDIO_I2S_GPIO_WS GPIO_NUM_18 #define AUDIO_I2S_GPIO_DOUT GPIO_NUM_19 // 向 ES8311 发送音频数据 #define AUDIO_CODEC_I2C_SDA_PIN GPIO_NUM_25 #define AUDIO_CODEC_I2C_SCL_PIN GPIO_NUM_32 #define AUDIO_CODEC_ES8311_ADDR ES8311_CODEC_DEFAULT_ADDR // 0x18 #define AUDIO_CODEC_GPIO_PA GPIO_NUM_NC // 无独立功放使能GPIO


在 m5paper.cc 中,GetAudioCodec() 创建了一个 Es8311AudioCodec 实例,所有参数都来源于 config.h:
virtual AudioCodec* GetAudioCodec() override {
if (audio_codec_ == nullptr) {
audio_codec_ = new Es8311AudioCodec(
i2c_bus_, // 手动创建的 I2C_NUM_0
EXT_I2C_NUM, // I2C_NUM_0
AUDIO_INPUT_SAMPLE_RATE, // 24000
AUDIO_OUTPUT_SAMPLE_RATE, // 24000
AUDIO_I2S_GPIO_MCLK, // NC
AUDIO_I2S_GPIO_BCLK, // GPIO 26
AUDIO_I2S_GPIO_WS, // GPIO 18
AUDIO_I2S_GPIO_DOUT, // GPIO 19
AUDIO_I2S_GPIO_DIN, // GPIO 33
AUDIO_CODEC_GPIO_PA, // NC
AUDIO_CODEC_ES8311_ADDR, // 0x18
false);
}
return audio_codec_;}注意:采样率统一为 24kHz,输入输出同频,简化了时钟配置。
M5Paper 的板载按键(A/B/C)已经带有外部上拉电阻(10kΩ)。如果直接使用 xiaozhi-esp32 的默认 Button 类,它会启用 ESP32 的内部上拉,与外部上拉并联后导致电平判断异常(按键灵敏度变低或无法触发)。
因此代码中自定义了 DisablePullButton:
class DisablePullButton : public Button {public:
DisablePullButton(gpio_num_t gpio_num) : Button(...) {
// ...
button_gpio_config_t gpio_config = {
.gpio_num = gpio_num,
.active_level = 0,
.enable_power_save = false,
.disable_pull = true // 禁用内部 pull-up
};
iot_button_new_gpio_device(&button_config, &gpio_config, &button_handle_);
}};按键功能映射:
GPIO 38(中间键 / Boot):单击切换对话状态,长按进入配网,启动时长按进入 WiFi 配网
GPIO 37(左键 / Vol+):单击音量 +10,长按音量 MAX
GPIO 39(右键 / Vol-):单击音量 -10,长按静音

5.3 睡眠模式与音频联动
为了省电,代码使用了 PowerSaveTimer:
power_save_timer_ = new PowerSaveTimer(240, 60, -1);// CPU max=240MHz, 60秒无操作进入睡眠, -1表示不自动关机
power_save_timer_->OnEnterSleepMode([this]() {
if (audio_codec_) {
audio_codec_->EnableInput(false); // 关闭麦克风省电
}});M5Paper 的屏幕由 IT8951 驱动控制器控制,分辨率为 960×540,支持 16 级灰度。本项目的显示适配核心在 m5paper_display.cc中完成。
不直接操作 IT8951 的 SPI 寄存器,而是通过 M5Unified + M5GFX 进行高层抽象:
void M5PaperDisplay::InitializeM5Unified() {
auto cfg = M5.config();
cfg.clear_display = true;
cfg.output_power = true;
cfg.internal_imu = true;
cfg.internal_rtc = true;
cfg.internal_spk = true; // 虽是板载speaker标记,但不影响外接模块
cfg.internal_mic = true;
M5.begin(cfg); // 自动检测并初始化 M5Paper 的 IT8951
M5.Display.setEpdMode(m5gfx::epd_mode_t::epd_fastest);
M5.Display.setRotation(0); // 竖屏:540x960}电子墨水屏的特殊性在于:局部刷新容易留下残影。因此本项目采用了 FULL 全屏刷新模式:
// 分配一整屏的 buffer(存放在 PSRAM 中) size_t buf_size = width * height * sizeof(lv_color_t); // RGB565 void* buf1 = heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM); lv_display_set_buffers(lv_display_, buf1, nullptr, buf_size, LV_DISPLAY_RENDER_MODE_FULL); lv_display_set_flush_cb(lv_display_, lvgl_flush_cb);
Flush 回调负责将 LVGL 渲染好的 RGB565 图像推送到电子墨水屏:
void M5PaperDisplay::FlushToEPD(const lv_area_t* area, const uint8_t* px_map) {
// M5GFX 自动将 RGB565 转换为 IT8951 支持的 4-bit 灰度
M5.Display.pushImage(x1, y1, w, h, (const uint16_t*)px_map);
// 触发 IT8951 刷新(关键:不调用 display() 则只写入内存,不会显示)
M5.Display.display();
lv_disp_flush_ready(lv_display_);
}M5Paper 的屏幕电源由两颗 GPIO 控制:
// 在 InitializeGpio() 中 #define EPD_MAIN_PWR_PIN GPIO_NUM_2 // 电子墨水屏主电源 #define EXT_PWR_EN_GPIO GPIO_NUM_5 // 外部电源使能 gpio_set_direction(EPD_MAIN_PWR_PIN, GPIO_MODE_OUTPUT); gpio_set_level(EPD_MAIN_PWR_PIN, true); gpio_set_direction(EXT_PWR_EN_GPIO, GPIO_MODE_OUTPUT); gpio_set_level(EXT_PWR_EN_GPIO, true);
同时,为了避免 M5Unified 对屏幕和音频引脚的预占用,代码在初始化时显式 reset 了相关 GPIO:
// 释放 GPIO 25/32,确保 ES8311 的 I2C 能正常接管 gpio_reset_pin(AUDIO_CODEC_I2C_SDA_PIN); gpio_reset_pin(AUDIO_CODEC_I2C_SCL_PIN);// 释放 I2S 引脚 gpio_reset_pin(AUDIO_I2S_GPIO_BCLK); gpio_reset_pin(AUDIO_I2S_GPIO_DOUT); gpio_reset_pin(AUDIO_I2S_GPIO_DIN);// 释放 EPD 的 SPI 数据引脚 gpio_reset_pin(EPD_GPIO_MOSI); gpio_reset_pin(EPD_GPIO_MISO); gpio_reset_pin(EPD_GPIO_SCK);// 注意:不 reset CS/RST/BUSY,因为它们由 IT8951 驱动自行管理
M5Paper 使用 AXP192/AXP2101 管理电源,M5Unified 已经封装好了接口。在 m5paper.cc 中重写了 GetBatteryLevel:
virtual bool GetBatteryLevel(int& level, bool& charging, bool& discharging) override {
level = M5.Power.getBatteryLevel();
auto charge_status = M5.Power.isCharging();
charging = (charge_status == m5::Power_Class::is_charging_t::is_charging);
discharging = (charge_status == m5::Power_Class::is_charging_t::is_discharging);
return true;
}该接口会被 xiaozhi-esp32 的 UI 层自动调用,在屏幕顶部显示电量百分比和充电状态。
通过 M5Unified 的 M5.In_I2C(即 I2C_NUM_1,GPIO 21/22)读取板载 SHT30 温湿度传感器(I2C 地址 0x44)。
代码中将其注册为了一个 MCP 工具,小智 AI 可以直接调用:
void InitializeMcpTools() {
auto& mcp_server = McpServer::GetInstance();
mcp_server.AddTool("self.environment.get_temperature",
"获取环境温度和湿度...",
PropertyList(),
[this](const PropertyList&) -> ReturnValue {
float temp = ReadSht30Temperature();
// 读取湿度
M5.In_I2C.start(0x44, false, 400000);
M5.In_I2C.write(0x2C);
M5.In_I2C.write(0x06);
M5.In_I2C.stop();
vTaskDelay(pdMS_TO_TICKS(20));
M5.In_I2C.start(0x44, true, 400000);
M5.In_I2C.read(data, 6, true);
M5.In_I2C.stop();
// CRC 校验后组装 JSON 返回
cJSON* result = cJSON_CreateObject();
cJSON_AddNumberToObject(result, "temperature", temp);
cJSON_AddNumberToObject(result, "humidity", humidity);
return result;
});}该项目基于 ESP32(带 4MB PSRAM),必须使用特定的 sdkconfig:
# 1. 设置目标为 ESP32idf.py set-target esp32 # 2. 复制 M5Paper 的 PSRAM 配置(必须!)cp main/boards/m5stack-m5paper/sdkconfig.m5paper sdkconfig # 3. 打开 menuconfig 选择 Boardidf.py menuconfig # 路径:Xiaozhi Assistant -> Board Type -> M5Stack M5Paper # 4. 编译idf.py build# 5. 烧录并监视idf.py flash monitor
config.json 中的预定义:
CONFIG_SPIRAM=y:启用 PSRAM(LVGL 全屏缓冲必需)
CONFIG_SPIRAM_SPEED=40:PSRAM 40MHz
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/v2/8m.csv":适配 8MB Flash
CONFIG_USE_MULTILINE_CHAT_MESSAGE=n:不开启多行聊天气泡
sdkconfig.m5paper 中的内存配置:
CONFIG_SPIRAM=y CONFIG_SPIRAM_BOOT_INIT=y CONFIG_SPIRAM_USE_MALLOC=y CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096 CONFIG_SPIRAM_SPEED=40
✅ 已验证功能:
外接 ES8311 音频编解码器驱动(I2C_NUM_0 隔离)
24kHz 音频输入/输出(GPIO 26/18/19/33)
IT8951 电子墨水屏 LVGL 显示(FULL 刷新无残影)
3 个实体按键完整映射(Boot / Vol+ / Vol-)
电池电量与充电状态实时显示
SHT30 温湿度读取(通过小智 AI 语音查询)
60 秒自动睡眠、任意操作唤醒
Wi-Fi 配网与 WebSocket 语音对话

视频:
本次适配的核心在于:正确使用 M5Unified 管理板载资源,同时在 I2C_NUM_0 上独立管理外接 ES8311 音频模块。通过将传感器总线与音频总线物理隔离,避免了地址冲突;通过 M5GFX + LVGL 的全屏缓冲策略,让电子墨水屏能够流畅显示语音助手的对话界面。
我要赚赏金
