这次我们来看一个针对 ESP32-S3 微控制器的 AVI 视频播放实现。对于嵌入式开发者而言,在资源受限的 MCU 上实现视频解码与播放一直是个挑战。ESP32-S3 凭借其双核处理器、充足的 PSRAM 和强大的外设接口,为这一目标提供了可能。本文将深入探讨如何在 ESP32-S3 上实现 AVI 视频播放,涵盖从环境搭建、库选型、代码解析到实际效果验证的全过程。如果你正在寻找一种在嵌入式设备上播放视频的轻量级方案,或者想了解 ESP32-S3 的多媒体潜力,这篇文章将提供直接的实践路径。
核心在于,这不是一个简单的文件流播放,而是涉及视频容器解析、解码器调用(如 MJPEG)、帧缓冲管理以及显示驱动(LCD/OLED)的完整技术栈。我们将重点关注方案的可行性、硬件资源门槛、具体的代码实现步骤,以及在实际项目中可能遇到的坑。文章将基于常见的开源库和 Arduino/ESP-IDF 开发环境,确保读者能够复现。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 核心功能 | 在 ESP32-S3 开发板上解析并播放 AVI 格式视频文件。 |
| 主要技术栈 | 基于 Arduino 框架或 ESP-IDF,使用AVI库进行容器解析,依赖JPEGDecoder等库处理 MJPEG 帧。 |
| 推荐硬件 | ESP32-S3 开发板(需搭载 PSRAM),SPI 或并行接口的 LCD 显示屏(如 ILI9341, ST7789)。 |
| 内存要求 | 强烈建议使用带有至少 8MB PSRAM 的 ESP32-S3 型号,用于缓存视频帧。 |
| 存储要求 | 视频文件需存储在 SD 卡或 SPIFFS/LittleFS 文件系统中。SD 卡读写速度影响播放流畅度。 |
| 支持视频编码 | 主要支持 Motion JPEG (MJPEG) 编码的 AVI 文件,这是嵌入式端最可行的方案。 |
| 显示输出 | 通过 SPI 或 8位并行接口驱动 LCD 屏幕。暂不支持 HDMI 等高清输出。 |
| 开发环境 | Arduino IDE 或 VS Code + PlatformIO,亦可使用 ESP-IDF。 |
| 关键依赖库 | TFT_eSPI(显示驱动),JPEGDecoder,AVI库(如ESP32_AVI_Player相关库)。 |
| 性能预期 | 帧率取决于视频分辨率、SPI 时钟频率、JPEG 解码速度,通常在 15-30 FPS(对于 QVGA 或更低分辨率)。 |
2. 适用场景与使用边界
这个方案非常适合需要在小尺寸屏幕上展示动态内容、进行产品演示或实现简单人机交互的嵌入式项目。例如,智能家居控制面板的欢迎动画、工业设备的操作指引视频、迷你游戏机的过场动画,或者物联网设备的状态可视化。
它能解决的核心问题是:在无需复杂多媒体协处理器或高端 MPU 的情况下,为 ESP32-S3 项目增加动态视频播放能力,丰富用户界面。
它不适合的场景包括:
- 高清视频播放:ESP32-S3 的计算能力和内存无法处理 H.264、HEVC 等现代视频编码。
- 高帧率或大分辨率视频:受限于 SPI 总线速度和 JPEG 解码耗时,播放 VGA 及以上分辨率视频可能卡顿。
- 复杂的音视频同步:本方案主要关注视频播放,音频处理需要额外编解码和输出设备,会增加复杂度和资源消耗。
- 长时间不间断播放:需注意芯片温升和存储设备的耐久性。
使用边界与合规提醒:播放的视频内容必须确保拥有合法版权或为自主创作。在涉及公众展示的产品中,务必确认内容授权。此外,处理来自外部存储设备的文件时,应注意数据安全,避免执行恶意构造的文件。
3. 环境准备与前置条件
在开始编码之前,需要准备好硬件和软件环境。
硬件清单:
- ESP32-S3 开发板:选择带有 PSRAM 的型号,例如 ESP32-S3-DevKitC-1-N8R8(8MB Flash, 8MB PSRAM)。PSRAM 用于缓存解码后的图像数据,至关重要。
- LCD 显示屏:一块 SPI 接口的 TFT 屏幕,如 2.4寸 ILI9341、1.54寸 ST7789。确保其驱动已被
TFT_eSPI库支持。 - 存储设备:Micro SD 卡模块(通过 SPI 连接)或利用开发板自带的部分 Flash 空间(通过 SPIFFS/LittleFS)。SD 卡更适合存放较大的视频文件。
- 连接线:杜邦线,用于连接 ESP32-S3 与 LCD 屏、SD 卡模块。
- 电源:确保提供稳定 5V/2A 以上的电源,视频解码和刷屏是耗电操作。
软件环境:
- 开发环境:推荐使用VS Code + PlatformIO,其库管理功能强大。也可以使用 Arduino IDE。
- ESP32 开发框架:在 PlatformIO 中,选择
ESP32-S3平台。在 Arduino IDE 中,确保已安装esp32开发板支持包(版本 2.0.x 或更高)。 - 必需库:我们将通过库管理器安装以下库:
TFT_eSPI:用于驱动 LCD 屏幕。JPEGDecoder:用于解码 AVI 文件中的 MJPEG 帧。ESP32_AVI_Player或类似库:一个专门用于在 ESP32 上播放 AVI 的库。你也可以使用更通用的AVI解析库结合文件操作。
4. 安装部署与库配置
4.1 创建项目与安装库
在 PlatformIO 中新建一个项目,选择开发板为Espressif ESP32-S3-DevKitC-1(或类似)。打开platformio.ini文件,添加依赖库。
[env:esp32-s3-devkitc-1] platform = espressif32 board = esp32-s3-devkitc-1 framework = arduino monitor_speed = 115200 lib_deps = bodmer/TFT_eSPI@^2.5.0 bodmer/JPEGDecoder@^1.9.0 https://github.com/bitluni/ESP32_AVI_Player # 示例库,可能需要根据实际情况选择在 Arduino IDE 中,可以通过“库管理器”搜索并安装TFT_eSPI和JPEGDecoder。对于ESP32_AVI_Player,可能需要从 GitHub 下载 ZIP 文件后通过“添加 .ZIP 库”安装。
4.2 配置 TFT_eSPI 库
这是关键一步。找到你 Arduino 库安装目录下的TFT_eSPI文件夹,打开User_Setup.h文件进行配置。
你需要根据你的屏幕型号和连接方式,注释掉不需要的驱动,并取消注释正确的驱动。例如,对于 ILI9341 屏幕,连接引脚如下:
// 假设连接方式: // SCLK -> GPIO 18 // MOSI -> GPIO 23 // DC -> GPIO 2 // CS -> GPIO 15 // RST -> GPIO 4 #define ILI9341_DRIVER // 取消注释此行,启用 ILI9341 驱动 #define TFT_WIDTH 240 #define TFT_HEIGHT 320 #define TFT_MISO -1 // 未连接 #define TFT_MOSI 23 #define TFT_SCLK 18 #define TFT_CS 15 #define TFT_DC 2 #define TFT_RST 4 #define LOAD_GLCD // 加载字体 #define LOAD_FONT2 #define LOAD_FONT4同时,确保在User_Setup_Select.h中,没有启用其他驱动导致冲突。通常只需正确配置User_Setup.h即可。
4.3 准备 AVI 视频文件
ESP32-S3 最适合播放MJPEG 编码的 AVI文件。你可以使用格式转换工具(如 FFmpeg)将普通视频转换为这种格式。
# 使用 FFmpeg 转换示例 ffmpeg -i input.mp4 -c:v mjpeg -q:v 10 -an output.avi-c:v mjpeg:指定视频编码为 Motion JPEG。-q:v 10:设置视频质量(2-31,值越小质量越高,文件越大)。10 是一个不错的平衡点。-an:移除音频流(简化处理)。
将转换得到的output.avi文件重命名为较短的名称(如test.avi),然后存入 Micro SD 卡根目录。
5. 功能实现与代码解析
我们以一个简化的播放流程为例,展示核心代码逻辑。这里假设使用一个基本的 AVI 解析思路,而非特定库的完整封装,以便理解原理。
5.1 基础代码框架
#include <FS.h> #include <SD.h> #include <SPI.h> #include <TFT_eSPI.h> #include <JPEGDecoder.h> TFT_eSPI tft = TFT_eSPI(); // 定义 AVI 文件结构的关键信息(简化) struct AVIInfo { uint32_t frameCount; uint32_t width; uint32_t height; uint32_t frameRate; uint32_t dataStart; // 视频数据开始的文件偏移量 }; AVIInfo aviInfo; File aviFile; void setup() { Serial.begin(115200); tft.init(); tft.setRotation(1); // 根据屏幕方向调整 tft.fillScreen(TFT_BLACK); // 初始化 SD 卡 if (!SD.begin(SS)) { // SS 通常是 SD 卡模块的 CS 引脚 Serial.println("SD Card Mount Failed"); tft.setTextColor(TFT_RED); tft.drawString("SD Card Fail", 10, 10, 4); while (1); } Serial.println("SD Card Initialized."); // 打开 AVI 文件并解析头部信息 if (!openAndParseAVI("/test.avi")) { Serial.println("Failed to open or parse AVI file."); while (1); } Serial.printf("AVI Info: %dx%d, %d frames, %d fps\n", aviInfo.width, aviInfo.height, aviInfo.frameCount, aviInfo.frameRate); } void loop() { playAVI(); // 播放一次后停止,实际可改为循环播放或受控播放 while(1) delay(1000); }5.2 解析 AVI 文件头
openAndParseAVI函数需要读取 AVI 文件的 RIFF 块、avih主头部信息和strh流头部信息。这是一个简化示例:
bool openAndParseAVI(const char* filename) { aviFile = SD.open(filename, FILE_READ); if (!aviFile) { Serial.println("Failed to open file"); return false; } // 读取 RIFF 头部 char riff[4]; aviFile.readBytes(riff, 4); if (strncmp(riff, "RIFF", 4) != 0) { Serial.println("Not a RIFF file"); return false; } aviFile.seek(8); // 跳过文件大小字段 char aviType[4]; aviFile.readBytes(aviType, 4); if (strncmp(aviType, "AVI ", 4) != 0) { Serial.println("Not an AVI file"); return false; } // 简化的查找过程:寻找 'avih' 和 'strh' 列表 // 在实际项目中,应使用更健壮的解析库,如 `ESP32_AVI_Player` // 这里仅为示意,假设我们通过某种方式找到了关键信息并填充了 aviInfo // aviInfo.dataStart = ...; // 找到第一个 '00dc' 或 '01wb' 块的位置(视频帧) // 示例:手动查找 'avih' 块 (简化,不适用于所有文件) bool found = false; uint8_t buffer[12]; while (aviFile.available() > 12) { aviFile.read(buffer, 4); if (strncmp((char*)buffer, "LIST", 4) == 0) { aviFile.read(buffer, 4); // 跳过列表大小 aviFile.read(buffer, 4); if (strncmp((char*)buffer, "hdrl", 4) == 0) { // 进入头部列表 found = true; break; } } } if (!found) return false; // ... 进一步解析 'avih' 和 'strh' 以获取宽度、高度、帧率、帧数等信息 // 这是一个复杂过程,建议使用现成库。 // 假设我们从库或成功解析中获得了以下信息(示例值): aviInfo.width = 240; aviInfo.height = 320; aviInfo.frameCount = 300; aviInfo.frameRate = 15; aviInfo.dataStart = 2048; // 视频数据开始偏移 return true; }5.3 播放循环与帧解码
playAVI函数负责读取每一帧(MJPEG 数据),解码并显示。
void playAVI() { if (!aviFile) return; aviFile.seek(aviInfo.dataStart); // 定位到视频数据开始处 for (uint32_t frameIdx = 0; frameIdx < aviInfo.frameCount; frameIdx++) { uint32_t chunkId, chunkSize; // 读取块标识和大小 aviFile.read((uint8_t*)&chunkId, 4); aviFile.read((uint8_t*)&chunkSize, 4); // 检查是否是视频帧块 ('00dc' 代表未压缩的视频数据,对于 MJPEG AVI 即是 JPEG 帧) if (chunkId != 0x63643030) { // '00dc' 的十六进制表示,注意小端序 // 可能是音频块('01wb')或其他,跳过 aviFile.seek(aviFile.position() + chunkSize + (chunkSize % 2)); // 块数据可能填充到偶数字节 frameIdx--; // 因为跳过的不是视频帧 continue; } // 读取 JPEG 帧数据到缓冲区 uint8_t* jpegData = (uint8_t*)ps_malloc(chunkSize); if (!jpegData) { Serial.println("Failed to allocate memory for frame"); break; } aviFile.read(jpegData, chunkSize); // 使用 JPEGDecoder 库解码并显示 bool decoded = decodeAndDisplayJPEG(jpegData, chunkSize); free(jpegData); // 释放帧数据内存 if (!decoded) { Serial.println("Frame decode failed"); // 可以选择继续或中断 } // 控制帧率 delay(1000 / aviInfo.frameRate); // 处理文件填充字节(如果存在) if (chunkSize % 2) aviFile.read(); // 跳过一个填充字节 } aviFile.close(); } bool decodeAndDisplayJPEG(uint8_t* data, uint32_t size) { // 使用 JPEGDecoder 库 JpegDec.decodeArray(data, size); if (JpegDec.width > tft.width() || JpegDec.height > tft.height()) { Serial.println("JPEG too large for screen"); return false; } // 将解码后的像素推送到屏幕 // 注意:这是一个简化的逐像素绘制,效率较低。实际应使用 setAddrWindow 和 pushPixels 优化。 tft.startWrite(); for (int y = 0; y < JpegDec.height; y++) { for (int x = 0; x < JpegDec.width; x++) { uint16_t pixel = JpegDec.pixels[y * JpegDec.width + x]; tft.drawPixel(x, y, pixel); } } tft.endWrite(); return true; }重要提示:上面的decodeAndDisplayJPEG函数使用drawPixel逐点绘制,效率极低,仅用于演示原理。在实际项目中,必须使用TFT_eSPI的pushImage或setAddrWindow配合 SPI 写入来批量传输像素数据,这是流畅播放的关键。
6. 性能优化与关键点
6.1 使用 PSRAM 作为帧缓冲区
ESP32-S3 的 PSRAM 是提升性能的关键。我们可以将解码后的整帧图像存放在 PSRAM 中,然后快速推送至屏幕。
#include <esp32-hal-psram.h> uint16_t* frameBuffer = nullptr; void setup() { // ... 其他初始化 if (psramFound()) { Serial.println("PSRAM Found and Initialized"); // 为 QVGA 图像分配缓冲区 frameBuffer = (uint16_t*)ps_malloc(320 * 240 * sizeof(uint16_t)); if (!frameBuffer) { Serial.println("PSRAM allocation failed!"); } } else { Serial.println("No PSRAM! Performance will be poor."); } } // 在 decodeAndDisplayJPEG 优化版本中 bool decodeAndDisplayJPEG_fast(uint8_t* data, uint32_t size) { JpegDec.decodeArray(data, size); if (JpegDec.width != 240 || JpegDec.height != 320) { // 假设屏幕是240x320 // 可能需要缩放,这里先简单返回 return false; } // 将解码数据直接存入 frameBuffer (PSRAM) uint16_t* pImg = JpegDec.pImage; // JPEGDecoder 解码后的数据指针 memcpy(frameBuffer, pImg, 320 * 240 * sizeof(uint16_t)); // 使用 pushImage 一次性将整个缓冲区发送到屏幕(极快) tft.pushImage(0, 0, 240, 320, frameBuffer); return true; }6.2 提高 SPI 时钟频率
TFT_eSPI库允许你设置 SPI 时钟频率。在User_Setup.h中,可以修改:
#define SPI_FREQUENCY 40000000 // 设置为 40 MHz,根据你的屏幕和布线质量调整,最高可达 80MHz更高的 SPI 频率能显著提升刷屏速度。
6.3 使用 DMA 传输
TFT_eSPI库支持 SPI DMA 传输,可以进一步解放 CPU。确保在User_Setup.h中启用:
#define SPI_DMA_CHANNEL 1 // 或 2,取决于 ESP32-S3 的可用 DMA 通道 #define USE_DMA启用 DMA 后,pushImage等操作将在后台进行,CPU 可以同时准备下一帧。
7. 资源占用与性能观察
内存占用:
- 栈/堆内存:主要的全局变量和局部缓冲区。使用 PSRAM 后,内部 RAM 压力不大。
- PSRAM:用于存储解码后的帧缓冲区。一帧 QVGA (320x240) 的 RGB565 图像需要
320*240*2 = 153,600字节(约 150KB)。双缓冲则需要约 300KB。 - 程序存储空间:包含库文件后,程序体积可能达到 1MB 以上,确保 Flash 空间充足。
性能观察点:
- 帧解码时间:在
decodeAndDisplayJPEG_fast函数前后使用micros()计时,观察 JPEG 解码耗时。MJPEG 解码相对较快。 - 屏幕刷新时间:
pushImage操作的耗时。这取决于 SPI 频率和 DMA 效率。 - SD 卡读取速度:使用
File.read()读取一帧数据的时间。Class 10 以上的 SD 卡是必须的。 - 总体帧率:在
playAVI循环中计算实际 FPS。目标是在目标分辨率下达到视频原生帧率(如 15fps 或 24fps)。
串口监控输出示例:
void playAVI() { // ... unsigned long totalDecodeTime = 0; unsigned long totalPushTime = 0; for (uint32_t frameIdx = 0; frameIdx < aviInfo.frameCount; frameIdx++) { // ... 读取数据 unsigned long startDecode = micros(); bool decoded = decodeAndDisplayJPEG_fast(jpegData, chunkSize); unsigned long endDecode = micros(); totalDecodeTime += (endDecode - startDecode); // ... 控制帧率 } Serial.printf("Avg Decode Time per frame: %lu us\n", totalDecodeTime / aviInfo.frameCount); // 类似方法可以测量 pushImage 时间 }8. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 屏幕白屏或花屏 | 1.TFT_eSPI驱动配置错误。2. 引脚连接错误或接触不良。 3. SPI 频率过高。 | 1. 检查User_Setup.h中的驱动型号、引脚定义。2. 用万用表检查连线。 3. 降低 SPI_FREQUENCY测试。 | 1. 对照屏幕规格书重新配置引脚。 2. 确保电源和地线连接牢固。 3. 逐步提高 SPI 频率直到稳定。 |
| SD 卡无法识别 | 1. 引脚连接错误(MISO, MOSI, SCK, CS)。 2. SD 卡格式不支持(非 FAT16/FAT32)。 3. 电源不足。 | 1. 运行 SD 卡示例程序测试。 2. 在电脑上重新格式化为 FAT32。 3. 检查电源电压和电流。 | 1. 确认 ESP32-S3 的 SPI 引脚与模块对应。 2. 使用正规品牌 SD 卡,容量不宜过大(建议 32GB 以下)。 3. 使用外部 5V 电源单独为 SD 模块供电测试。 |
| 播放卡顿、帧率低 | 1. SD 卡读取速度慢。 2. JPEG 解码耗时过长。 3. 屏幕刷新慢(SPI 频率低)。 4. 未使用 PSRAM 或 DMA。 | 1. 测量读取一帧数据的时间。 2. 测量解码一帧的时间。 3. 测量 pushImage时间。4. 检查 PSRAM 是否初始化,DMA 是否启用。 | 1. 换用 Class 10/UHS-I 的高速卡。 2. 优化 JPEG 解码,或降低视频分辨率/质量。 3. 提高 SPI_FREQUENCY,启用 DMA。4. 确保使用带 PSRAM 的型号,并在代码中正确分配缓冲区。 |
| 程序崩溃或重启 | 1. 内存不足(堆溢出)。 2. PSRAM 分配失败。 3. 文件读取越界。 | 1. 查看串口崩溃日志(回溯信息)。 2. 检查 psramFound()和ps_malloc返回值。3. 检查 AVI 解析逻辑,确保文件指针操作正确。 | 1. 减少全局变量,使用 PSRAM 存储大缓冲区。 2. 确认开发板支持 PSRAM,并在 IDE 中启用 PSRAM 选项( Board: "ESP32S3 Dev Module"->PSRAM: "OPI PSRAM")。3. 使用更健壮的 AVI 解析库,或仔细调试文件读取代码。 |
| AVI 文件无法解析 | 1. 文件不是 MJPEG 编码的 AVI。 2. AVI 文件头结构复杂,解析代码有 bug。 3. 文件路径错误。 | 1. 用 PC 播放器(如 VLC)检查文件属性。 2. 串口打印读取的块 ID 和大小,与十六进制查看器对比。 3. 确认 SD 卡根目录下文件名正确。 | 1. 使用 FFmpeg 重新转换,确保编码为mjpeg。2.强烈建议使用成熟的第三方库,如 ESP32_AVI_Player,避免重复造轮子。3. 使用 SD.exists()检查文件。 |
| 颜色显示异常 | 1. RGB 颜色顺序配置错误。 2. JPEG 解码后的像素格式与屏幕期望格式不匹配。 | 1. 在TFT_eSPI的User_Setup.h中调整TFT_RGB_ORDER。2. 检查 JPEGDecoder输出的像素格式。 | 1. 尝试切换TFT_RGB_ORDER为0或1。2. 确保 pushImage函数调用时颜色格式正确(通常是 RGB565)。 |
9. 最佳实践与使用建议
- 从简单开始:先使用
TFT_eSPI库的示例程序点亮屏幕并显示静态图片,确保硬件连接和基础配置正确。 - 分阶段验证:
- 阶段一:成功从 SD 卡读取文件并打印其大小。
- 阶段二:成功解析 AVI 头部信息并打印出宽度、高度、帧数。
- 阶段三:成功读取并解码单帧 JPEG,显示在屏幕上。
- 阶段四:实现完整的播放循环,并优化性能。
- 视频预处理是关键:
- 分辨率:匹配或低于你的屏幕分辨率。缩放会消耗大量 CPU 时间。
- 帧率:15-20 fps 对于嵌入式 UI 通常足够。更高的帧率对硬件要求呈指数增长。
- 编码:严格使用 MJPEG。检查 FFmpeg 转换命令的输出,确认视频流编码为
mjpeg。 - 文件大小:考虑 Flash 或 SD 卡的容量。可以通过降低
-q:v参数来减小文件。
- 管理资源:
- 在
loop()之外打开文件,播放完毕后再关闭,避免频繁的文件系统操作。 - 使用
ps_malloc从 PSRAM 分配大块内存,并在使用后及时free。 - 如果播放多个视频,考虑在切换时释放旧资源再加载新资源。
- 在
- 错误处理:在文件读取、内存分配、解码等环节添加充分的错误检查和日志输出,便于快速定位问题。
- 功耗考虑:连续视频播放功耗较高。如果设备是电池供电,需要评估播放时长,或增加休眠机制。
10. 总结与下一步
在 ESP32-S3 上播放 AVI 视频是一个验证其多媒体处理能力的绝佳项目。核心挑战不在于概念,而在于对有限资源的精细管理:PSRAM 的运用、SPI 总线的优化、以及高效的 JPEG 解码与显示流水线。
最值得尝试的点是,利用 ESP32-S3 相对充裕的 PSRAM 和 DMA 能力,实现足够流畅的 QVGA 视频播放,这为许多嵌入式设备的动态交互打开了新思路。你应该最先验证的是硬件基础:屏幕驱动和 SD 卡读写。这两个环节通了,后续的视频解析和播放就成功了一大半。
最容易踩的坑集中在配置层面:TFT_eSPI的引脚配置、PSRAM 的启用、以及 AVI 文件格式的严格匹配。强烈建议在初期直接使用经过验证的第三方播放库,而不是从头实现 AVI 解析器,这能节省大量时间。
完成基础播放后,可以探索的下一步方向包括:
- 增加音频:使用 I2S 接口连接 MAX98357 等音频 DAC 模块,播放 AVI 中的音频流(需要解析
01wb块并解码 PCM 数据)。 - 触摸控制:集成触摸屏,实现视频播放/暂停、进度跳转。
- 网络流播放:从 SPIFFS 或 SD 卡播放扩展到通过 Wi-Fi 从网络服务器获取视频片段(对网络稳定性和缓冲要求高)。
- UI 集成:将视频播放作为复杂 UI 系统(如 LVGL)的一个组件,实现更丰富的交互逻辑。
这个项目清晰地展示了 ESP32-S3 作为一款高性能 MCU 的潜力。虽然它无法替代专业的视频播放芯片,但在成本、功耗和集成度要求严格的场景下,提供了一个非常具有吸引力的软件解决方案。建议收藏本文的配置要点和排查清单,在开发过程中随时参考。