1. 从一次串口数据丢失说起:为什么需要环形缓冲区
那天下午,我正在调试一个基于ESP32的智能家居网关项目。设备通过串口(UART)与一个温湿度传感器通信,每秒上报一次数据。在实验室里,一切运行完美。但当我把它部署到实际环境中,连接上Wi-Fi并开始通过MQTT向云端上报数据时,奇怪的事情发生了:串口接收到的数据时不时会丢失一两个字节,导致解析出的温湿度值完全错误,日志里满是校验失败的错误信息。
最初的怀疑对象是电源干扰或波特率不匹配,但经过示波器抓取波形和长时间静态测试,排除了硬件问题。问题出在软件上。当Wi-Fi中断重连或MQTT发布数据包时,系统会进入一个相对繁忙的状态,此时如果串口中断服务程序(ISR)被触发,接收到的数据需要被立刻存起来。我的原始做法是,在串口ISR里,直接将数据拷贝到一个全局数组里,然后设置一个标志位,主循环检测到这个标志位就去处理这个数组。
// 最初的“简陋”方案 - 问题代码示例 #define UART_BUF_SIZE 256 uint8_t uart_raw_buffer[UART_BUF_SIZE]; volatile bool uart_data_ready = false; volatile uint16_t uart_data_len = 0; // 在串口中断服务程序中 void IRAM_ATTR uart_isr_handler(void) { while (uart_has_data()) { uint8_t byte = uart_read_byte(); if (uart_data_len < UART_BUF_SIZE) { uart_raw_buffer[uart_data_len++] = byte; } // 假设以换行符为结束 if (byte == '\n') { uart_data_ready = true; } } } // 在主循环中 void loop() { if (uart_data_ready) { process_data(uart_raw_buffer, uart_data_len); uart_data_ready = false; uart_data_len = 0; // 重置索引 } // ... 其他任务,如网络处理 }这个方案在低负载时工作良好,但在高负载或中断被短暂屏蔽时,数据竞争(Data Race)和缓冲区覆盖的问题就暴露出来了。想象一下这个场景:主循环刚执行完process_data的第一行,还没来得及将uart_data_len重置为0,此时一个高优先级的网络中断到来,并在其中断处理中又触发了串口中断。新的数据就会从uart_raw_buffer[0]开始覆盖,而uart_data_len还在指向旧数据的长度,导致新旧数据混杂,process_data处理的是错乱的数据。这就是典型的生产者(串口ISR)-消费者(主循环)速度不匹配且共享资源保护不当导致的问题。
为了解决这类问题,在嵌入式领域,尤其是像乐鑫ESP8266/ESP32这样资源有限、常面临异步事件(网络、蓝牙、传感器)的MCU上,环形缓冲区(Ring Buffer,或 Circular Buffer)成为一种核心的数据结构。它不是乐鑫的发明,但却是乐鑫ESP-IDF框架、AT指令栈、网络协议栈内部大量使用的基础构件。理解它,你就能理解很多乐鑫代码中数据流转的底层逻辑,进而写出更稳定、高效的嵌入式程序。
简单说,环形缓冲区就是一个逻辑上首尾相连的线性存储空间。它通过两个指针(或索引)——一个指向头部(读位置),一个指向尾部(写位置)——来管理数据的存取。当指针到达缓冲区末端时,它会绕回到开头,形成一个“环”。这种结构完美契合了生产者持续产生数据、消费者不定期消化数据的流式处理模型,无需频繁移动大量数据,也自然避免了上述简单全局数组方案的数据竞争问题(配合正确的临界区保护)。接下来,我们将深入乐鑫的代码世界,看看环形缓冲区是如何被实现和应用的。
2. 乐鑫代码中的环形缓冲区实现剖析
乐鑫的SDK(无论是ESP8266 NONOS SDK、ESP8266 RTOS SDK还是ESP32的ESP-IDF)中,并没有一个命名为“ring_buffer”的独立、通用的模块供用户直接调用。相反,环形缓冲区作为一种设计模式,被内化在了各个需要它的驱动和组件中。理解这一点很重要:你不是在调用一个“RingBuffer API”,而是在理解一种遍布代码的设计思想。我们可以通过分析典型实例来掌握其精髓。
2.1 UART驱动中的环形缓冲区:最经典的案例
串口驱动是使用环形缓冲区最典型的地方。以ESP-IDF的UART驱动为例(components/driver/uart.c),其核心是一个uart_rx_buffer_t结构体(或类似的内嵌缓冲区管理),虽然为了效率其内部实现可能更复杂,但其思想与环形缓冲区一致。
我们可以构建一个简化模型来理解。在用户初始化UART时,可以指定接收缓冲区的大小:
uart_config_t uart_config = { .baud_rate = 115200, .data_bits = UART_DATA_8_BITS, .parity = UART_PARITY_DISABLE, .stop_bits = UART_STOP_BITS_1, .flow_ctrl = UART_HW_FLOWCTRL_DISABLE }; uart_param_config(UART_NUM_1, &uart_config); // 设置一个1024字节的环形接收缓冲区 const int rx_buffer_size = 1024; uart_driver_install(UART_NUM_1, rx_buffer_size, 0, 0, NULL, 0);驱动内部,这个rx_buffer_size的内存会被组织成一个环形缓冲区。当硬件UART接收到一个字节时,触发中断,中断服务程序(ISR)执行最精简的操作:
- 从UART FIFO读取数据字节。
- 将字节写入环形缓冲区的尾部指针位置。
- 尾部指针向前移动一位(如果到达末尾则绕回)。
- 更新一个代表当前数据量的计数器(或通过头尾指针计算)。
这个过程非常快,且不会在ISR内进行任何数据解析、打印等耗时操作。ISR只负责“搬运”数据。
而在应用程序层面,你通过uart_read_bytes函数来消费数据:
uint8_t data[128]; int len = uart_read_bytes(UART_NUM_1, data, sizeof(data), 20 / portTICK_PERIOD_MS);这个函数内部所做的工作是:
- 从环形缓冲区的头部指针位置读取数据。
- 将数据拷贝到用户提供的
data数组。 - 头部指针向前移动
len位。 - 返回实际读取的长度。
为什么这种方式能解决数据竞争?关键在于,头指针和尾指针的修改,在ISR和任务上下文访问时,需要通过临界区(如关闭中断、使用互斥锁)进行保护,确保指针操作的原子性。而数据本身存放在固定的线性数组中,ISR只写尾指针之后的空间,任务只读头指针之后的空间,只要两者不重叠,读写操作就是安全的。缓冲区满或空的状态通过头尾指针的关系来判断,避免了覆盖未读数据或读取无效数据。
注意:这里的“指针”在具体实现中可能是数组索引(
uint32_t类型),因为环形缓冲区通常基于数组实现。head和tail索引值的比较和运算需要特别注意无符号整数的溢出和回绕问题。
2.2 FreeRTOS队列(Queue):环形缓冲区的高阶抽象
如果你使用乐鑫的RTOS SDK(ESP-IDF基于FreeRTOS),那么你每天都在间接使用环形缓冲区。FreeRTOS的队列(Queue)就是一个线程安全、功能更强的环形缓冲区抽象。
队列不仅管理了一块内存区域,还封装了任务阻塞、唤醒机制。当任务尝试从一个空队列读取数据时,它可以被阻塞,直到有数据写入;当任务尝试向一个满队列写入数据时,它也可以被阻塞,直到有空间空出。这完美解决了生产者-消费者的同步问题。
在乐鑫的代码中,队列被广泛用于任务间通信。例如,Wi-Fi事件处理、网络套接字数据传递、传感器数据采集任务与数据处理任务之间的通信等。
// 创建一个可以存放10个数据结构的队列,每个元素是一个 sensor_data_t 结构体 QueueHandle_t sensor_queue = xQueueCreate(10, sizeof(sensor_data_t)); // 生产者任务(如传感器读取) void sensor_task(void *pvParameters) { sensor_data_t data; while (1) { read_sensor(&data); // 读取传感器 // 将数据发送到队列,如果队列满则等待最多100个ticks if (xQueueSend(sensor_queue, &data, 100 / portTICK_PERIOD_MS) != pdTRUE) { ESP_LOGE(TAG, "Failed to send sensor data to queue, may be full."); } vTaskDelay(1000 / portTICK_PERIOD_MS); // 每秒读一次 } } // 消费者任务(如数据上传) void upload_task(void *pvParameters) { sensor_data_t data; while (1) { // 从队列接收数据,如果队列空则永久等待 if (xQueueReceive(sensor_queue, &data, portMAX_DELAY) == pdTRUE) { send_to_cloud(&data); // 处理数据,如上传云端 } } }xQueueCreate内部就是分配了一块大小为10 * sizeof(sensor_data_t)的内存作为环形缓冲区。xQueueSend和xQueueReceive则自动管理头尾索引和任务调度。对于ESP32开发者而言,直接使用FreeRTOS队列是比手动实现环形缓冲区更推荐、更安全的方式,除非你在性能极其苛刻的场合(如高波特率串口的ISR内部)。
2.3 网络协议栈与DMA:环形缓冲区的深度应用
在更底层的网络驱动(如Wi-Fi、Ethernet)中,环形缓冲区经常与DMA(直接内存访问)结合使用,以实现零拷贝(Zero-copy)的高性能数据吞吐。
例如,当ESP32通过Wi-Fi接收一个网络数据包时:
- 硬件MAC和DMA控制器会自动将收到的数据包直接搬运到事先定义好的一块内存区域(一个环形缓冲区池中的某个缓冲区)。
- DMA操作完成后,产生一个中断。
- 中断服务程序并不处理数据本身,而是仅仅更新一个描述符(Descriptor),告知协议栈(如LwIP)有一个新的数据包在某个环形缓冲区中可用。
- LwIP协议栈从环形缓冲区中取出这个数据包进行处理(如解析IP、TCP头)。
- 处理完毕后,协议栈将这个缓冲区归还给缓冲区池,以便DMA下一次使用。
这个过程避免了CPU在ISR中进行大量数据拷贝,极大地提高了吞吐量和实时性。这里的“环形缓冲区池”通常是一个更复杂的管理结构,但核心思想依然是环形复用。
3. 自己动手实现一个简易环形缓冲区
理解了乐鑫代码中的设计思想后,自己实现一个用于特定场景(比如在裸机环境或对性能有特殊要求时)的环形缓冲区是很有价值的。这不仅有助于加深理解,也能让你在无法使用RTOS队列时游刃有余。
下面我们实现一个面向单生产者(如串口ISR)、单消费者(如主循环)的字节流环形缓冲区。这是嵌入式中最常见的场景。
3.1 数据结构定义与初始化
// ring_buffer.h #ifndef RING_BUFFER_H #define RING_BUFFER_H #include <stdint.h> #include <stdbool.h> typedef struct { uint8_t *buffer; // 指向缓冲区内存的指针 uint16_t capacity; // 缓冲区总容量 volatile uint16_t head; // 读索引(消费者使用),需注意volatile volatile uint16_t tail; // 写索引(生产者使用),需注意volatile // 注意:在ISR和主循环中访问的变量必须用volatile修饰,防止编译器优化错误。 } ring_buffer_t; /** * @brief 初始化环形缓冲区 * @param rb 指向环形缓冲区结构体的指针 * @param pool 用户提供的内存池(数组) * @param size 内存池的大小(字节) */ void ring_buffer_init(ring_buffer_t *rb, uint8_t *pool, uint16_t size); /** * @brief 向环形缓冲区写入一个字节(生产者调用) * @param rb 指向环形缓冲区结构体的指针 * @param data 要写入的字节 * @return true 写入成功,false 缓冲区已满写入失败 */ bool ring_buffer_put(ring_buffer_t *rb, uint8_t data); /** * @brief 从环形缓冲区读取一个字节(消费者调用) * @param rb 指向环形缓冲区结构体的指针 * @param data 指向存放读取字节的变量的指针 * @return true 读取成功,false 缓冲区为空读取失败 */ bool ring_buffer_get(ring_buffer_t *rb, uint8_t *data); /** * @brief 获取环形缓冲区中可读数据的数量 * @param rb 指向环形缓冲区结构体的指针 * @return 可读字节数 */ uint16_t ring_buffer_available(ring_buffer_t *rb); /** * @brief 获取环形缓冲区中空闲空间的数量 * @param rb 指向环形缓冲区结构体的指针 * @return 空闲字节数 */ uint16_t ring_buffer_free(ring_buffer_t *rb); /** * @brief 清空环形缓冲区 * @param rb 指向环形缓冲区结构体的指针 */ void ring_buffer_clear(ring_buffer_t *rb); #endif // RING_BUFFER_H// ring_buffer.c #include "ring_buffer.h" void ring_buffer_init(ring_buffer_t *rb, uint8_t *pool, uint16_t size) { rb->buffer = pool; rb->capacity = size; rb->head = 0; rb->tail = 0; } static inline uint16_t _next_index(uint16_t current, uint16_t capacity) { // 计算下一个索引位置,实现回绕 return (current + 1) % capacity; } bool ring_buffer_put(ring_buffer_t *rb, uint8_t data) { uint16_t next_tail = _next_index(rb->tail, rb->capacity); // 判断缓冲区是否已满:尾指针的下一个位置等于头指针 if (next_tail == rb->head) { return false; // 缓冲区满,写入失败 } rb->buffer[rb->tail] = data; rb->tail = next_tail; return true; } bool ring_buffer_get(ring_buffer_t *rb, uint8_t *data) { // 判断缓冲区是否为空:头指针等于尾指针 if (rb->head == rb->tail) { return false; // 缓冲区空,读取失败 } *data = rb->buffer[rb->head]; rb->head = _next_index(rb->head, rb->capacity); return true; } uint16_t ring_buffer_available(ring_buffer_t *rb) { // 计算可读数据量,注意无符号数的回绕计算 if (rb->tail >= rb->head) { return rb->tail - rb->head; } else { return rb->capacity - (rb->head - rb->tail); } } uint16_t ring_buffer_free(ring_buffer_t *rb) { // 空闲空间 = 总容量 - 已用空间 - 1 (因为要区分空和满的状态) // 一种常见的实现是预留一个位置来区分空和满。 // 我们当前的设计就是如此:当 (tail+1)%capacity == head 时认为满。 // 所以空闲空间是 capacity - 1 - available。 uint16_t used = ring_buffer_available(rb); // 防止used计算错误导致下溢 return (rb->capacity - 1) - used; } void ring_buffer_clear(ring_buffer_t *rb) { // 清空缓冲区只需重置头尾指针,无需擦除内存数据 rb->head = 0; rb->tail = 0; }3.2 在串口通信中的实战应用
现在,我们用这个自制的环形缓冲区重写文章开头那个有问题的串口示例:
#include "ring_buffer.h" #define UART_RB_CAPACITY 512 uint8_t uart_rb_pool[UART_RB_CAPACITY]; ring_buffer_t uart_rb; // 初始化 void app_main() { ring_buffer_init(&uart_rb, uart_rb_pool, UART_RB_CAPACITY); uart_config_t uart_config = {...}; uart_param_config(UART_NUM_1, &uart_config); // 注意:这里我们不使用驱动内部的缓冲区,或者将其设得很小,用自己的RB uart_driver_install(UART_NUM_1, 128, 0, 0, NULL, 0); // 内部buf仅作FIFO // 设置中断回调,在回调中读取UART FIFO并存入我们的环形缓冲区 uart_isr_handle_t handle; uart_isr_register(UART_NUM_1, uart_isr_handler, NULL, ESP_INTR_FLAG_IRAM, &handle); uart_enable_rx_intr(UART_NUM_1); } // 优化的串口中断处理程序(简化版,实际需处理错误) void IRAM_ATTR uart_isr_handler(void *arg) { uint8_t data; while (uart_read_bytes(UART_NUM_1, &data, 1, 0) > 0) { // 尝试放入环形缓冲区,如果满了就丢弃(或可设置标志) if (!ring_buffer_put(&uart_rb, data)) { // 缓冲区满,可以设置一个溢出错误标志 // uart_rb_overflow = true; break; // 跳出循环,避免在ISR中长时间阻塞 } } // ... 清除中断标志等 } // 主循环中处理数据 void loop() { uint8_t byte; static uint8_t line_buffer[128]; static int line_index = 0; // 非阻塞地读取环形缓冲区中的所有可用数据 while (ring_buffer_get(&uart_rb, &byte)) { // 简单的行解析逻辑 line_buffer[line_index++] = byte; if (byte == '\n' || line_index >= sizeof(line_buffer) - 1) { line_buffer[line_index] = '\0'; // 添加字符串结束符 process_data(line_buffer, line_index); line_index = 0; // 重置行缓冲区 } } // ... 其他任务 }关键改进点:
- 解耦:ISR只负责快速将硬件FIFO的数据转移到自定义环形缓冲区,不做复杂处理。
- 线程安全:我们的
ring_buffer_put和ring_buffer_get函数本身不是原子的,但在单生产者(ISR)单消费者(主循环)场景下,只要确保put和get内部对head和tail的读写是原子的(对于8/16位变量在大多数架构上是原子的),且编译器不进行有害优化(使用volatile),就是安全的。更严谨的做法是在put和get函数内部使用临界区(如portENTER_CRITICAL_ISR/portEXIT_CRITICAL_ISR)。 - 溢出处理:当缓冲区满时,我们有明确的策略(丢弃新数据并记录错误),而不是像之前那样静默覆盖,这便于问题追踪。
4. 环形缓冲区使用中的进阶问题与调优
掌握了基础实现后,在实际项目中使用环形缓冲区还会遇到一些进阶问题。处理不好,它们依然是潜在的“坑”。
4.1 多生产者/多消费者场景下的同步
前面的例子是单生产者单消费者(SPSC)。如果你的系统中有多个任务(或中断)同时向同一个缓冲区写数据(多生产者),或者多个任务同时读数据(多消费者),那么简单的volatile就不够了,必须引入同步机制。
解决方案:
- 使用互斥锁(Mutex):在
ring_buffer_put和ring_buffer_get函数入口加锁,出口解锁。这是最通用的方法,但锁的开销较大,特别是在ISR中不能直接使用普通的互斥锁(会导致上下文切换错误)。 - 使用信号量(Semaphore):可以用两个信号量分别表示“空闲空间数量”和“可用数据数量”。生产者先获取“空闲空间”信号量,然后写入数据,再释放“可用数据”信号量;消费者反之。这更符合生产者-消费者模型,效率也较高。
- 使用原子操作(Atomic Operations):对于C11及以上标准或使用GCC/Clang内置原子函数,可以使用原子变量和原子操作来无锁地更新头尾指针。这是性能最高的方式,但实现复杂,容易出错。
- 利用硬件特性或RTOS提供的无锁队列:在ESP32上,最省心的做法就是直接使用FreeRTOS的队列(
xQueueSendFromISR和xQueueReceiveFromISR),它已经为你处理好了所有同步问题。
建议:在ESP32开发中,除非性能瓶颈证明必须优化,否则优先使用FreeRTOS队列。它的稳定性和功能完整性远胜于自己实现的无锁缓冲区。
4.2 缓冲区大小的选择与性能权衡
缓冲区容量capacity的选择是个艺术,需要权衡:
- 太小:容易溢出,导致数据丢失。尤其是在突发大量数据时(如网络数据包、传感器突发上报)。
- 太大:浪费宝贵的RAM资源(ESP8266/ESP32的内存并不宽裕),同时可能增加数据处理的延迟(旧数据在缓冲区中停留时间更长)。
设计原则:
- 估算峰值数据速率和消费速率:缓冲区大小应能平滑掉生产者和消费者之间的最大速率差所持续时间内产生的数据量。例如,生产者最大突发速率是1KB/s,消费者最慢处理速率是500B/s,处理延迟最大2秒,那么缓冲区至少需要
(1000 - 500) * 2 = 1000字节。 - 考虑最坏情况:比如Wi-Fi断开重连期间,数据无法上传,但传感器仍在采集。缓冲区需要能撑过整个重连周期。
- 使用动态监测:在调试版本中,可以添加统计代码,记录缓冲区的最大使用量(
peak_usage),为最终确定容量提供依据。 - 分而治之:不要用一个巨大的缓冲区处理所有事情。为不同的数据流(如UART1, UART2, 传感器数据, 网络命令)使用独立的、大小合适的缓冲区,可以降低单个缓冲区溢出的风险,也便于模块化管理。
4.3 如何高效处理“数据包”而非“字节流”
我们的示例是面向字节流的。但很多时候,我们需要处理的是具有固定格式或明确边界的数据包(如Modbus RTU帧、自定义协议帧)。
策略一:在环形缓冲区层面实现包读取修改ring_buffer_get函数族,增加一个ring_buffer_get_packet函数,它需要知道包的长度或能够识别包边界(如特定的起始符和结束符)。这需要在缓冲区中搜索,实现稍复杂,且在包不完整时需要“回退”读取位置。
策略二:使用双层结构(更推荐)
- 第一层:基础的字节流环形缓冲区(如我们实现的)。
- 第二层:协议解析器。它从字节流缓冲区中读取数据,并维护一个解析状态机。当状态机识别出一个完整的包时,将其提交给业务逻辑处理。
typedef enum { PKT_STATE_WAIT_START, PKT_STATE_IN_LENGTH, PKT_STATE_IN_DATA, PKT_STATE_WAIT_END } packet_parser_state_t; typedef struct { ring_buffer_t *rb; // 底层的字节流环形缓冲区 packet_parser_state_t state; uint16_t expected_len; uint16_t current_idx; uint8_t packet_buffer[MAX_PACKET_LEN]; } packet_parser_t; bool parse_packet(packet_parser_t *parser, void (*packet_callback)(uint8_t*, uint16_t)) { uint8_t byte; while (ring_buffer_get(parser->rb, &byte)) { switch (parser->state) { case PKT_STATE_WAIT_START: if (byte == START_BYTE) { parser->state = PKT_STATE_IN_LENGTH; parser->current_idx = 0; } break; case PKT_STATE_IN_LENGTH: // 假设长度域是2个字节 ((uint8_t*)&(parser->expected_len))[parser->current_idx++] = byte; if (parser->current_idx >= 2) { parser->state = PKT_STATE_IN_DATA; parser->current_idx = 0; if (parser->expected_len > MAX_PACKET_LEN) { // 长度异常,重置状态机 parser->state = PKT_STATE_WAIT_START; } } break; case PKT_STATE_IN_DATA: parser->packet_buffer[parser->current_idx++] = byte; if (parser->current_idx >= parser->expected_len) { parser->state = PKT_STATE_WAIT_END; } break; case PKT_STATE_WAIT_END: if (byte == END_BYTE) { // 找到一个完整包! if (packet_callback) { packet_callback(parser->packet_buffer, parser->expected_len); } } // 无论是否匹配结束符,都回到开始状态寻找下一个包 parser->state = PKT_STATE_WAIT_START; break; } } return false; // 本次调用未解析出完整包 }这种方式分离了数据存储(环形缓冲区)和协议解析(状态机),结构清晰,易于维护和调试。这也是乐鑫AT指令栈、网络协议栈处理流式数据的常见模式。
4.4 调试与问题排查技巧
当你的环形缓冲区工作不正常时(数据丢失、错乱),可以按以下步骤排查:
- 检查缓冲区大小:首先确认
capacity设置是否合理。通过打印ring_buffer_available的峰值来验证。 - 验证同步机制:如果是多线程/多中断访问,务必检查临界区保护是否正确。在ESP32上,可以在
put/get函数前后加上portENTER_CRITICAL/portEXIT_CRITICAL来测试问题是否消失(注意ISR版本portENTER_CRITICAL_ISR)。 - 检查指针计算:重点检查
_next_index函数和ring_buffer_available函数中的索引计算,特别是当tail < head时的回绕计算是否正确。一个常见的错误是使用了signed int导致负数问题,或者计算空闲空间时没有考虑预留位。 - 使用内存屏障:在极少数对性能要求极高且自己实现无锁队列时,需要在更新
head/tail指针后插入内存屏障(如__sync_synchronize()或portMEMORY_BARRIER()),确保写入顺序对其他核心/线程可见。 - 添加调试信息:在调试版本中,可以为环形缓冲区添加一个“魔术字”或校验和,每次操作后检查缓冲区的完整性。或者,在
put和get时打印指针值,观察其变化是否符合预期。 - 模拟压力测试:编写一个测试任务,以最高速率向缓冲区写入数据,另一个任务以随机延迟读取,运行一段时间后检查数据是否完整、顺序是否正确。
环形缓冲区是嵌入式软件中一颗低调但至关重要的“齿轮”。它在乐鑫的代码中无处不在,默默支撑着从串口到Wi-Fi的各种数据流。理解其原理,掌握其实现,并能根据具体场景灵活运用和调试,是嵌入式开发者从“能用”走向“稳定高效”的关键一步。下次当你阅读乐鑫的驱动源码或编写自己的中断处理程序时,不妨多留意一下,那些精妙的数据流转背后,很可能就藏着一个环形缓冲区的身影。