1. 项目概述:为什么RISC-V移植是当下的硬核必修课
最近几年,但凡关注嵌入式、物联网或者处理器架构的朋友,耳朵都快被“RISC-V”这个词磨出茧子了。它不再仅仅是学术论文里的概念,而是实实在在地出现在各种开发板、芯片产品,甚至是手机和服务器原型里。我作为一个在嵌入式领域摸爬滚打了十几年的老鸟,深切感受到,现在不会点RISC-V的移植,出门跟同行聊天都差点意思。这玩意儿已经从一个“可选项”,变成了很多场景下的“必选项”。
那么,到底什么是“RISC-V移植”?简单说,就是把原本为其他处理器架构(比如我们最熟悉的ARM Cortex-M系列,或者x86)编写的软件,经过一番“改造”,让它能在基于RISC-V指令集的处理器上跑起来。这个“软件”范围很广,可能是你写的应用程序,也可能是像FreeRTOS、LVGL、lwIP这样的开源中间件或操作系统,甚至是像U-Boot、Linux Kernel这样的底层系统软件。这个过程,就像是给一个软件“换心脏”——原来的心脏(ARM指令集)有一套特定的跳动(执行)方式,现在要换上一颗全新的、更开放的心脏(RISC-V指令集),你得确保身体(软件)的所有机能(功能)都能适应并正常运转。
为什么这件事现在这么火?核心就三点:自由、成本和生态。RISC-V是开源的指令集架构,这意味着公司可以免去昂贵的架构授权费,根据自家产品需求定制处理器,这在追求极致成本控制的IoT领域简直是杀手锏。其次,一个开放的架构避免了被单一供应商锁定的风险。最后,也是最重要的一点,生态正在以肉眼可见的速度成熟。有需求,有芯片,自然就需要大量的软件移植工作。所以,无论是为了跟上技术趋势,还是为了解决手头的实际问题,掌握RISC-V移植这项技能,都变得至关重要。接下来,我就结合自己踩过的坑和积累的经验,把这门“手艺”的里里外外给你拆解明白。
2. 移植工作的核心思路与前期准备
2.1 理解移植的本质:差异映射与填补空白
很多人一听到“移植”就觉得头大,感觉要重写大量代码。其实不然,成熟的移植更多是“适配”而非“重造”。它的核心思路可以概括为:识别差异,建立映射,填补空白。
首先,识别差异。你需要对比源架构(例如ARM)和目标架构(RISC-V)在以下几个关键层面的不同:
- 指令集:这是根本。ARM是复杂指令集(CISC)风格的精简指令集(RISC),而RISC-V是更纯粹、模块化的RISC。你需要关注基本整数指令集(I)、乘除法(M)、原子操作(A)、单/双精度浮点(F/D)等扩展是否都具备。比如,你的代码里用了硬件浮点运算,那目标RISC-V核心就必须支持F或D扩展。
- 寄存器组:ARM Cortex-M有R0-R15通用寄存器和一些特殊寄存器。RISC-V有x0-x31通用寄存器,以及像
mstatus(机器状态)、mepc(机器异常程序计数器)、mtvec(机器陷阱向量基址)等控制状态寄存器(CSR)。寄存器的数量、功能和命名规则完全不同。 - 异常/中断处理模型:ARM有固定的中断向量表,通过NVIC管理。RISC-V通常使用
mtvec寄存器指向一个统一的异常处理入口,再通过mcause等CSR判断异常原因。这个处理流程需要重新实现。 - 内存模型与地址空间:虽然都是冯·诺依曼结构,但具体的内存布局(如Flash、RAM的起始地址)、内存保护单元(MPU)的配置方式可能不同。
- 编译器工具链:从ARM的arm-none-eabi-gcc切换到RISC-V的riscv-none-elf-gcc。编译选项、链接脚本(.ld文件)的语法和需要定义的符号截然不同。
其次,建立映射。为那些依赖于特定架构的代码找到RISC-V上的等价物。例如,ARM中开关全局中断的__disable_irq()和__enable_irq()内联汇编,在RISC-V上就需要用操作mstatus寄存器中的MIE(机器中断使能)位来实现。
最后,填补空白。RISC-V架构本身只定义了“机器模式”(M-mode)的CSR和基本行为,很多具体实现留给了芯片厂商或软件。因此,你需要实现或移植一些底层硬件抽象层(HAL)和启动代码。这部分是移植中最具挑战性也最核心的工作。
2.2 工具链选型:第一道关口的抉择
工欲善其事,必先利其器。RISC-V移植的第一步就是搭建编译环境。目前主流的选择有:
- GNU工具链(riscv-none-elf-gcc):这是最通用、社区支持最广的选择。SiFive、芯来科技等厂商都提供基于GCC的预编译工具链。它稳定,兼容性好,适合大多数裸机和RTOS移植。
- LLVM/Clang工具链:越来越流行的选择,编译速度快,模块化好,对现代C++标准支持更佳。在一些追求编译效率或使用复杂C++特性的项目中是更好的选择。
- IAR for RISC-V:商业编译器,以其优秀的代码优化和调试体验著称。如果你的项目原先就在IAR for ARM上开发,且公司有预算,迁移到IAR for RISC-V会平滑很多,尤其是涉及浮点上下文切换这种棘手问题时,IAR提供的运行时库支持可能更完善。
实操心得:对于初学者和大多数开源项目,我强烈建议从GNU工具链开始。你可以从芯来科技(Nuclei)或SiFive的GitHub页面下载预编译版本。这能帮你避开复杂的编译工具链自身的坑,把精力集中在软件移植本身。确保你的工具链路径已加入系统环境变量,并用
riscv-none-elf-gcc -v命令验证安装。
2.3 目标板评估:硬件是移植的基石
在写第一行移植代码前,你必须彻底了解你的目标RISC-V开发板或芯片:
- 核心与扩展:它用的是哪家公司的RISC-V核心?(如芯来的Nuclei N系列,平头哥的C906,还是SiFive的E系列?)支持哪些标准扩展(I, M, A, F, D, C)?这直接决定了你能使用哪些指令。
- 内存映射:Flash和SRAM的起始地址和大小是多少?有没有额外的外设存储区域?这是编写链接脚本和启动代码的绝对依据。
- 外设与中断控制器:时钟系统(PLL)、GPIO、UART、定时器、中断控制器(PLIC)的寄存器地址和编程模型是什么?通常芯片厂商会提供硬件抽象层(HAL)驱动库或参考代码,这是你的宝贵财富。
- 调试接口:支持JTAG还是SWD?对应的OpenOCD配置文件是什么?没有可靠的调试手段,移植工作将举步维艰。
我的做法是,创建一个board.h或bsp_<board_name>.h头文件,把这些硬件关键信息以宏定义的形式集中管理,例如:
// board.h #define SYSTEM_CLOCK_FREQ 8000000UL // 外部晶振8MHz #define FLASH_BASE 0x80000000 #define SRAM_BASE 0x90000000 #define SRAM_SIZE (64 * 1024) // 64KB #define UART0_BASE 0x100000003. 启动流程与底层环境构建
3.1 链接脚本(.ld)的重写
链接脚本是告诉链接器如何把代码、数据安排到内存中的蓝图。从ARM换到RISC-V,链接脚本必须重写。一个最简化的RISC-V裸机链接脚本骨架如下:
/* linker.ld */ MEMORY { FLASH (rx) : ORIGIN = 0x80000000, LENGTH = 256K RAM (rwx) : ORIGIN = 0x90000000, LENGTH = 64K } SECTIONS { .text : { /* 中断向量表入口(通常是一个跳转到_start的指令或直接是_start地址) */ KEEP(*(.vectors)) *(.text .text.*) /* 所有代码段 */ KEEP(*(.rodata .rodata.*)) /* 只读数据 */ } > FLASH .data : ALIGN(4) { _sdata = .; /* 全局变量,标记.data段在RAM中的加载地址 */ *(.data .data.*) _edata = .; } > RAM AT > FLASH /* AT > FLASH 表示内容在Flash中,运行时需拷贝到RAM */ .bss : ALIGN(4) { _sbss = .; /* 全局变量,标记.bss段起始 */ *(.bss .bss.*) *(COMMON) _ebss = .; /* 全局变量,标记.bss段结束 */ } > RAM _stack_top = ORIGIN(RAM) + LENGTH(RAM); /* 设置栈顶指针 */ }关键点:
> RAM AT > FLASH这个语法是GNU LD的特色,表示.data段的运行时地址(VMA)在RAM,但它的内容(加载地址,LMA)存放在FLASH中。启动时需要一段代码将其拷贝到RAM。_sdata,_edata,_sbss,_ebss这些符号会在启动代码中被引用,用于数据搬运和BSS段清零。- 栈顶指针
_stack_top通常设置在RAM的末尾。
3.2 启动文件(startup.s)的实现
启动文件是用汇编写的,是芯片上电后执行的第一段代码。它的核心任务包括:
- 设置栈指针(SP):从链接脚本中获取
_stack_top值,加载到SP寄存器(x2)。 - 初始化.data段:将存储在Flash中的已初始化全局变量(.data段)拷贝到RAM中其运行时地址。
- 清零.bss段:将未初始化的全局变量(.bss段)所在内存区域清零。
- 初始化中断向量表:设置
mtvec寄存器,使其指向异常处理函数。 - 跳转到main函数:完成上述初始化后,调用C语言的
main()函数。
下面是一个极度简化的RISC-V汇编启动代码示例(使用GNU汇编语法):
/* startup.S */ .section .vectors, "ax" .global _start _start: /* 1. 设置栈指针 */ la sp, _stack_top /* 2. 拷贝.data段 (VMA在RAM, LMA在Flash) */ la a0, _sdata /* RAM中的目标地址 */ la a1, _edata la a2, _data_load /* 这个符号需要在链接脚本中定义,指向Flash中的.data段起始 */ 1: beq a0, a1, 2f lw t0, 0(a2) sw t0, 0(a0) addi a0, a0, 4 addi a2, a2, 4 j 1b 2: /* 3. 清零.bss段 */ la a0, _sbss la a1, _ebss 3: beq a0, a1, 4f sw zero, 0(a0) addi a0, a0, 4 j 3b 4: /* 4. 设置机器陷阱向量(简化版,直接设置为异常处理函数地址) */ la t0, trap_handler csrw mtvec, t0 /* 5. 跳转到C语言main函数 */ call main /* main函数不应返回,若返回则进入死循环 */ 1: j 1b /* 简单的异常/中断处理函数 */ .section .text .global trap_handler trap_handler: /* 保存上下文(这里极度简化,实际需要保存所有可能用到的寄存器) */ addi sp, sp, -32*4 sw x1, 1*4(sp) /* ... 保存其他寄存器 ... */ /* 读取mcause判断原因 */ csrr t0, mcause /* 根据t0的值进行不同处理(中断、非法指令、访存错误等) */ /* 恢复上下文 */ lw x1, 1*4(sp) /* ... 恢复其他寄存器 ... */ addi sp, sp, 32*4 mret注意事项:上面的上下文保存/恢复是极度简化的。在实际的RTOS(如FreeRTOS)或复杂应用中,需要实现完整的上下文切换,这涉及到保存/恢复所有的通用寄存器(x1-x31)和浮点寄存器(f0-f31,如果使用FPU),以及一些CSR。这是移植RTOS时最复杂的部分之一。
3.3 系统初始化与时钟配置
在进入main()之前或之后,通常需要初始化系统时钟、外设等。这部分代码高度依赖具体芯片,你需要参考厂商的SDK或数据手册。一个典型的流程是:
- 配置时钟源(如内部RC振荡器或外部晶体)。
- 配置锁相环(PLL)倍频,得到系统核心时钟(HCLK)。
- 配置总线时钟分频(AHB, APB)。
- 初始化必要的外设,如用于调试输出的UART。
// system_init.c void SystemInit(void) { // 1. 配置时钟 // 例如:使能外部高速时钟(HSE),等待就绪 // 配置PLL,将HSE*N倍频,选择PLL作为系统时钟源 // 具体寄存器操作依芯片而定 // ... // 2. 初始化调试串口(UART0) // 配置GPIO复用为UART功能 // 配置UART波特率、数据位、停止位等 // ... }在main()函数的最开始调用SystemInit()。
4. 操作系统与中间件的移植实战
当底层启动和硬件抽象层(HAL)就绪后,就可以着手移植更上层的软件了。这里以FreeRTOS和LVGL为例,讲解核心要点。
4.1 FreeRTOS移植到RISC-V
FreeRTOS的移植主要集中在三个文件:port.c,portmacro.h, 和portasm.S(或类似的汇编文件)。你需要从FreeRTOS官方或芯片厂商提供的RISC-V移植范例中获取这些文件。
核心工作:
- 中断与异常处理集成:修改
portasm.S中的中断入口,使其与你的启动文件trap_handler衔接。FreeRTOS需要接管中断,以便进行任务调度。关键是将mtvec设置为FreeRTOS提供的中断统一入口freertos_risc_v_trap_handler。 - 上下文切换:实现
vPortYield(主动任务切换)和中断触发的上下文切换。这需要编写精密的汇编代码,保存和恢复所有任务上下文(包括通用寄存器、PC、状态寄存器,以及浮点寄存器如果使能了FPU)。 - 系统节拍器(Tick):配置一个硬件定时器(如RISC-V的
mtime/mtimecmp寄存器或芯片的通用定时器),产生固定频率(如1kHz)的中断,在中断服务程序(ISR)中调用xPortSysTickHandler()。 - 堆栈初始化:在
port.c中,正确初始化每个任务的堆栈帧,模拟一次中断返回后的现场,使得第一次调度到该任务时,能正确“恢复”到它的入口函数。 - 关键段保护:实现
portENTER_CRITICAL()和portEXIT_CRITICAL(),通常通过操作mstatus的MIE位来开关全局中断。
避坑指南:浮点上下文切换:这是RISC-V移植FreeRTOS的一个大坑。如果任务中使用了浮点运算,且硬件支持FPU,那么在上下文切换时必须保存和恢复浮点寄存器(f0-f31)。否则,当一个浮点任务被切换出去,另一个任务修改了浮点寄存器后,再切换回来,计算结果会完全错误。你需要:
- 在
portmacro.h中定义portTASK_USES_FLOATING_POINT()。- 在上下文切换汇编代码中,检查任务的浮点标志,动态决定是否保存/恢复浮点寄存器组。这增加了上下文切换的复杂性,但至关重要。IAR或GCC的RISC-V工具链会提供相关的运行时库支持,但你需要确保在FreeRTOS的移植层正确调用这些机制。
4.2 LVGL图形库移植到RISC-V
LVGL是一个资源占用小的嵌入式图形库,其移植相对独立于底层RTOS和硬件,主要实现几个“对接”接口:
显示驱动(Display Driver):
- 初始化函数:配置你使用的显示接口(如SPI、8080并口、RGB接口),初始化屏幕控制器。
- 刷新函数:这是核心。LVGL会在内部渲染好一帧图像(存储在
color_buf数组)后,调用这个函数。你需要在这个函数里,将color_buf中的像素数据通过你的接口发送到屏幕。对于帧率要求不高的屏幕,可以使用DMA或中断来优化,避免阻塞主循环。
static void disp_flush(lv_disp_drv_t * drv, const lv_area_t * area, lv_color_t * color_map) { // 将color_map中的数据,根据area指定的区域,写入屏幕 // 例如,通过SPI发送“设置窗口坐标”命令,然后发送像素数据流 lv_disp_flush_ready(drv); // 必须调用此函数告知LVGL刷新完成 }输入设备驱动(Input Device Driver):
- 如果你有触摸屏,需要实现
touchpad_read函数,定期(如在定时器中断中)读取触摸坐标和状态(按下/释放),并调用lv_indev_read()或相关函数上报给LVGL。 - 如果是按键,则实现
keypad_read函数。
- 如果你有触摸屏,需要实现
心跳与时基:LVGL需要毫秒级的心跳来驱动动画、定时器等。你需要在系统Tick中断(如FreeRTOS的
vApplicationTickHook)或一个独立的硬件定时器中断中,定期调用lv_tick_inc(1)。任务调度器:如果运行在裸机环境,你需要在主循环中调用
lv_timer_handler()。如果运行在FreeRTOS下,可以创建一个专有的LVGL任务,在该任务中循环调用lv_timer_handler(),并配合信号量或通知机制与显示刷新、输入读取等中断服务程序通信。
移植LVGL的关键在于优化显示刷新速度。对于没有硬件加速的RISC-V MCU,全屏刷新大量像素可能很慢。可以尝试:
- 使用LVGL的局部刷新特性,只刷新变化的区域。
- 使用双缓冲,让LVGL在后台缓冲区渲染下一帧,同时前台缓冲区正在发送数据到屏幕。
- 如果接口支持,尽量使用DMA传输像素数据,解放CPU。
5. 外设驱动与HAL层适配
5.1 通用外设驱动模型
对于UART、SPI、I2C、PWM等通用外设,虽然RISC-V核心本身不定义它们,但芯片厂商会提供寄存器级的驱动。你的任务是将这些底层驱动封装成与原有项目(例如基于STM32 HAL库)兼容的API接口。
例如,原STM32项目使用HAL_UART_Transmit()发送数据。在RISC-V平台上,你可能需要实现一个同样函数名的封装函数,内部调用厂商SDK的UART发送函数。
// uart_hal.c (RISC-V 适配层) #ifdef TARGET_RISCV HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout) { // 调用RISC-V芯片厂商的UART发送API if (uart_send_blocking(huart->Instance, pData, Size, Timeout) == 0) { return HAL_OK; } else { return HAL_ERROR; } } #endif这种方式可以最小化应用层代码的修改。
5.2 中断控制器(PLIC)配置
RISC-V标准定义了平台级中断控制器(PLIC)来处理外部中断。与ARM的NVIC不同,PLIC的优先级和使能配置更为灵活但也稍显复杂。你需要:
- 初始化PLIC:设置全局阈值,使能特定中断源。
- 编写中断服务程序(ISR):在统一的异常处理函数(
trap_handler)中,通过读取mcause判断是外部中断,然后查询PLIC的claim寄存器获取最高优先级的中断ID,跳转到对应的ISR函数执行,执行完毕后向PLIC的complete寄存器写入该中断ID。 - 中断嵌套:RISC-V默认不支持硬件中断嵌套。如果需要,需要在进入ISR后手动操作
mstatus寄存器再次打开全局中断(MIE位),但这需要非常小心地保存和恢复上下文。
6. 调试、优化与常见问题排查
6.1 调试手段搭建
没有调试的移植如同盲人摸象。务必搭建好调试环境:
- OpenOCD + GDB:这是最常用的开源调试组合。你需要为目标板编写或找到对应的OpenOCD配置文件(.cfg),该文件描述了芯片的JTAG/SWD接口、内存映射等。然后通过OpenOCD启动GDB服务器,在IDE(如VSCode)或命令行中使用GDB进行连接、单步、断点、查看寄存器/内存。
- 串口打印:最原始但最可靠。在系统初始化早期就初始化一个UART,实现
printf重定向到串口(通过重写_write等系统调用)。大量的调试信息可以通过串口输出。 - Segger RTT:如果板子支持J-Link,Segger的RTT(实时传输)技术是极佳的调试补充,它通过JTAG接口传输调试信息,速度极快,不占用串口。
6.2 性能分析与优化
移植完成后,代码可能运行得不如在ARM上流畅。需要进行性能分析:
- 使用GPIO翻转计时:在关键代码段开始和结束处翻转一个GPIO,用示波器测量脉冲宽度,这是最直接的硬件计时方法。
- 使用循环计数器:RISC-V的
mcycle和minstretCSRs分别提供周期数和指令数计数,可以用于粗略的性能分析。 - 优化点:
- 编译器优化等级:尝试
-O2或-Os(优化尺寸)。-O3激进优化有时反而会增加代码尺寸或引入不稳定。 - 关键函数用汇编重写:对性能瓶颈函数(如内存拷贝
memcpy、图形处理算法)用RISC-V汇编精心优化。 - 利用RISC-V特性:如果核心支持C扩展(压缩指令),确保编译器启用(
-march=rv32imc),可以显著减少代码体积。合理使用原子扩展(A)进行无锁编程。
- 编译器优化等级:尝试
6.3 常见问题速查与解决
以下是我在多次RISC-V移植中遇到的典型问题及解决方案:
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 程序上电后毫无反应,连最早期的串口打印都没有。 | 1. 启动文件未正确执行(栈指针设置错误、.data段拷贝失败)。 2. 链接脚本中内存地址与硬件不匹配。 3. 系统时钟未正确配置,CPU跑在错误频率或未起振。 | 1. 用调试器连上,看PC指针是否停在_start。单步跟踪启动汇编,检查SP值、数据拷贝循环。2. 核对芯片数据手册,修正链接脚本的 ORIGIN和LENGTH。3. 检查时钟配置代码,用示波器测量主时钟输出引脚(如果有)。 |
| 程序运行一段时间后死机或进入异常。 | 1. 栈溢出。 2. 中断向量表配置错误或中断服务程序(ISR)未正确清除中断标志。 3. 内存访问越界(如数组溢出)。 4. 浮点上下文未正确保存(在使用FPU的RTOS中)。 | 1. 增加栈大小,或在FreeRTOS中开启堆栈溢出检测钩子函数。 2. 检查 mtvec设置,在异常处理函数中打印mcause和mepc寄存器值,定位异常原因。确保ISR清除硬件中断标志。3. 使用静态分析工具或加强代码审查。 4. 确认FreeRTOS移植中浮点上下文切换代码被正确编译和执行。 |
| 串口能打印,但输出乱码。 | 1. 系统时钟频率配置错误,导致波特率计算偏差。 2. 串口驱动初始化时序问题(如未等时钟稳定就配置)。 | 1. 重新计算波特率分频值,确保与PC端串口工具设置一致。用示波器测量串口TX引脚波形,计算实际波特率。 2. 在系统时钟稳定后,延迟一段时间再初始化串口外设。 |
| FreeRTOS任务调度不起来。 | 1. 系统Tick定时器中断未正确配置或未触发。 2. 上下文切换汇编代码有bug,导致寄存器恢复错误。 3. 任务堆栈分配不足。 | 1. 确认Tick定时器已使能并产生中断,在Tick ISR中设置一个GPIO翻转,用示波器查看是否定期触发。 2. 使用调试器单步跟踪 vPortYield或中断上下文切换的汇编代码,观察寄存器保存/恢复过程。3. 增大任务堆栈,或使用FreeRTOS的 uxTaskGetStackHighWaterMark检查栈使用情况。 |
| LVGL刷新极其缓慢,动画卡顿。 | 1. 显示刷新函数disp_flush是阻塞式写入,且未使用DMA。2. lv_timer_handler调用频率太低。3. 绘制区域过大,或使用了复杂的渲染效果(如阴影、渐变)。 | 1. 将刷新改为非阻塞,使用DMA传输。在DMA传输完成中断中调用lv_disp_flush_ready。2. 确保 lv_tick_inc被定期调用(每1-10ms),并提高调用lv_timer_handler的频率(如在一个高优先级任务中循环调用)。3. 简化UI,减少全局刷新,利用LVGL的局部刷新机制。 |
7. 从移植到深度定制:超越基础
完成基本移植只是第一步。要充分发挥RISC-V的优势,可以考虑更深层次的定制:
- 自定义指令集成:一些RISC-V芯片允许用户添加自定义指令。如果你的算法有非常密集的计算核心(如特定的加密、图像处理),可以尝试用自定义指令来实现,在硬件层面获得巨大的性能提升。这需要芯片厂商的工具链支持和硬件设计配合。
- 电源管理优化:RISC-V架构在低功耗设计上有天然优势。深入研究芯片的休眠模式(Sleep, DeepSleep),在RTOS的空闲任务钩子函数中,根据系统状态进入不同的低功耗模式,并合理管理外设时钟,可以极大延长电池寿命。
- 安全特性实践:如果芯片支持RISC-V的物理内存保护(PMP)或用户模式(U-mode),可以尝试在RTOS中实现简单的任务隔离,提升系统的稳健性。例如,为不同的FreeRTOS任务配置不同的PMP区域,防止任务间的非法内存访问。
移植一个系统到RISC-V,就像是在一片充满可能性的新大陆上重建家园。初期的一砖一瓦(启动代码、驱动适配)固然辛苦,但当你看到原本属于ARM的软件生态在自己的RISC-V板上流畅运行,那种成就感是无与伦比的。这个过程会让你对计算机体系结构、编译链接、操作系统原理有前所未有的深刻理解。记住,多查阅官方文档(RISC-V特权架构手册、芯片数据手册)、多利用社区资源(GitHub上的开源移植项目)、多动手调试,每一个坑踩过去,都是实打实的经验积累。