1. 项目概述:协处理器系统集成到底在解决什么问题?
“第20章:协处理器系统集成”这个标题,乍看像教科书里的章节名,但背后藏着现代计算架构演进中最关键的一次范式转移——当CPU这台“全能老司机”越来越力不从心,系统设计者不再硬着头皮让它干所有活,而是给它配一个或多个“专项副驾”:GPU做图像与AI加速,FPGA做低延迟信号处理,NPU专攻神经网络推理,DSP专注音频/通信算法……这些就是协处理器。而“系统集成”,不是简单地把芯片焊在板子上、连几根线就完事,它是让主处理器(Host CPU)和协处理器(Accelerator)之间建立起一套高效、可靠、可维护的协同机制:数据怎么传?任务怎么分?状态怎么同步?错误怎么兜底?资源怎么仲裁?驱动怎么写?软件栈怎么分层?这些问题全堆在“第20章”的标题之下。
我带过好几个嵌入式系统项目,从工业视觉检测设备到边缘AI网关,凡是涉及实时性要求高、算力密度大、功耗约束严的场景,协处理器都不是“锦上添花”,而是“生死线”。比如某款车载ADAS模块,原始方案用四核ARM A72跑YOLOv5,帧率卡在8fps,发热严重;换成一颗轻量级NPU协处理器后,主CPU只负责调度与I/O,NPU专责模型推理,整机帧率跃升至28fps,功耗下降43%,温升控制在安全阈值内——这不是性能提升,是系统可用性的质变。所以,“协处理器系统集成”本质上是一套软硬协同的工程方法论,它横跨硬件接口设计、固件开发、驱动适配、中间件抽象、应用编程模型五个层面,任何一个环节掉链子,整个加速效果就打七折。它适合三类人深度参考:一是正在做SoC/FPGA原型验证的硬件工程师,需要理解如何把加速单元接入AMBA总线;二是Linux BSP开发者,得搞定DMA映射、中断注册、sysfs节点暴露;三是算法部署工程师,必须清楚自己写的TensorFlow Lite模型,最终是如何被调度到NPU上执行的。如果你还在用system("ffmpeg -i ...")这种粗暴方式调用硬件加速,那这一章,就是你该补上的第一课。
2. 系统集成的整体设计思路与方案选型逻辑
2.1 为什么不能“直连直调”?——主从协同的本质矛盾
很多初学者一上来就想“让CPU直接读写协处理器寄存器”,这在理论上可行,但实操中会踩出一片雷区。根本原因在于:CPU和协处理器通常运行在不同时钟域、不同电压域、不同缓存一致性域。举个具体例子:某款国产AI芯片的NPU工作在500MHz,而主控CPU运行在1.6GHz,两者间没有硬件级Cache Coherency(如ACE协议),如果CPU修改了一块共享内存,NPU可能还在用旧缓存数据,结果就是推理输出错乱。更麻烦的是中断响应——NPU完成一次推理后发中断,若CPU正在执行一段关闭中断的临界区代码(比如操作自旋锁),这个中断就会丢失,任务永远卡死。所以,“集成”第一步,不是写代码,而是建立一套受控的通信契约。
我们团队在某次医疗影像加速项目中就吃过这个亏。早期版本采用“轮询+共享内存”模式:CPU把CT图像数据拷贝到DDR指定地址,写入NPU命令寄存器,然后循环读取状态寄存器等待完成。看似简单,但实测发现,在高负载下CPU占用率飙升至92%,因为轮询消耗了大量周期;更致命的是,当系统发生内存碎片时,共享内存页被换出,NPU访问触发MMU fault,整个系统挂起。后来彻底重构为“中断驱动+DMA+描述符队列”模式,CPU只需初始化一次DMA通道,提交任务描述符(含输入/输出地址、尺寸、配置参数),之后完全释放,由NPU硬件自动搬运数据、执行、触发中断。CPU响应中断后,仅需检查完成队列,开销降低到不足3%。这个转变的核心,就是从“CPU中心主义”转向“硬件自治主义”。
2.2 四种主流集成架构对比:选型不是技术炫技,而是成本与风险的平衡
市面上协处理器集成方案并非只有一种标准答案,而是根据性能、实时性、开发周期、维护成本四个维度权衡出的四种典型路径。我们用一张表说清本质差异:
| 集成架构 | 数据通路 | 控制通路 | 典型适用场景 | 开发难度 | 实时性保障 | 维护成本 |
|---|---|---|---|---|---|---|
| 寄存器直控(Register-Mapped I/O) | CPU通过AXI/APB总线直接读写NPU寄存器及片上SRAM | 同步阻塞调用 | 超低功耗MCU、传感器融合预处理(如加速度计+陀螺仪数据融合) | ★★☆☆☆(低) | 弱(无硬件同步机制) | 低(固件小,易烧录) |
| DMA+中断(DMA + Interrupt) | CPU配置DMA控制器,数据经AXI-Stream或AXI-MM搬移;NPU处理完毕触发中断 | 中断服务程序(ISR)处理状态 | 工业PLC、实时运动控制(如伺服电机PID闭环) | ★★★☆☆(中) | 强(硬件DMA保证零拷贝,中断及时响应) | 中(需精细调优中断优先级与ISR时长) |
| 消息队列+RPC(Message Queue + RPC) | 数据经共享内存或高速片上NoC(Network-on-Chip)传输 | 通过Mailbox或Shared Memory中的环形缓冲区传递指令 | 多核异构SoC(如ARM+RISC-V+NPU)、车载域控制器 | ★★★★☆(高) | 中(依赖软件队列管理,有调度延迟) | 高(需实现完整RPC框架与序列化) |
| 统一虚拟内存(Unified Virtual Memory, UVM) | CPU与NPU共享同一套页表,指针可直接传递 | 通过统一内存管理器(UMM)协调分配与迁移 | 高性能计算(HPC)、云边协同推理(如Kubernetes中NPU Pod调度) | ★★★★★(极高) | 极强(硬件级地址翻译与一致性) | 极高(需定制MMU、TLB、Page Fault Handler) |
我们曾为某款智能摄像头选型,纠结于DMA和RPC两种方案。最终选择DMA,理由很实在:产品生命周期要求5年免维护,而RPC框架一旦升级内核或更换RTOS,整个通信层都要重验;DMA方案虽然初期调试耗时多2周,但后续三年没动过一行驱动代码。这就是“选型逻辑”——它不取决于哪个更酷,而取决于哪个能让产线工人、售后工程师、测试同事少掉几根头发。
2.3 总线与互连:AMBA家族是事实标准,但细节决定成败
绝大多数SoC级协处理器都基于ARM AMBA总线协议族互联,这是行业共识。但AMBA不是铁板一块,APB、AHB、AXI三大协议适用场景截然不同:
APB(Advanced Peripheral Bus):低速外设总线,最大优势是面积小、功耗低、时序简单。适合连接配置寄存器、状态寄存器这类“慢速控制通道”。某次我们设计一款电池供电的语音唤醒芯片,NPU的配置寄存器就挂在APB上,CPU每秒只写入几次,却省下了0.8mm²的芯片面积和12μA静态电流。
AHB(Advanced High-performance Bus):中速总线,支持突发传输(Burst),适合连接片上SRAM、ROM等中等带宽资源。但它的握手协议(HREADY信号)在高频下易出时序违例,我们曾因AHB时钟树未做充分仿真,在量产前发现NPU加载权重失败,返工改版损失2个月。
AXI(Advanced eXtensible Interface):高性能总线,最大特点是分离读写地址/数据通道、支持乱序完成、内置ID标记。这才是协处理器数据通路的黄金标准。AXI4-Stream专为流式数据(如视频帧、雷达点云)优化,无地址概念,纯靠TVALID/TREADY握手机制,吞吐量轻松突破10GB/s。我们在某5G基站基带处理模块中,将FPGA实现的FFT加速器通过AXI4-Stream接入SoC,实测单次1024点FFT延迟稳定在3.2μs,远超传统PCIe方案。
提示:AXI协议里有个极易被忽略的坑——ID字段的语义一致性。AXI规范要求同一ID的读写请求必须按序完成,但很多开源AXI Interconnect IP核(尤其某些FPGA厂商提供的)默认ID位宽为0,即所有请求ID=0,导致多任务并发时顺序错乱。我们曾因此在多路视频分析中出现帧序颠倒,排查三天才发现是IP核配置漏设ID宽度。务必在集成前用AXI Protocol Checker工具抓波形验证ID行为。
3. 核心细节解析与实操关键点
3.1 内存一致性:没有Cache Coherency,一切加速都是空中楼阁
协处理器加速失效的头号杀手,不是算力不够,而是内存不一致。CPU写入的数据,协处理器读不到;协处理器写回的结果,CPU看到的是旧值。这个问题在Linux系统中尤为隐蔽,因为内核的Cache管理策略(如dma_alloc_coherentvsdma_alloc_noncoherent)会掩盖底层硬件缺陷。
我们以一个真实案例说明:某客户反馈其AI推理SDK在ARM Cortex-A76平台上输出结果随机错误。抓取DDR内存波形发现,CPU写入的输入图像数据,NPU读取时地址对应位置却是0x00000000。根源在于:该SoC的NPU并未接入ACE(AXI Coherency Extensions)总线,而客户驱动错误地使用了dma_alloc_noncoherent分配缓冲区,导致CPU写入后未执行__clean_dcache_area刷新D-Cache,NPU直接从DDR读取了脏数据。修复方案只有两条路:要么改用dma_alloc_coherent(牺牲部分性能换取一致性),要么在每次CPU写入后手动执行Cache Clean操作。
这里给出一份经过产线验证的Cache操作checklist:
确认硬件能力:查阅SoC Reference Manual,搜索关键词“ACE”、“Snoop Control Unit (SCU)”、“Cache Coherent Interconnect”。若无相关章节,基本可判定不支持硬件一致性。
驱动层强制策略:
- 若硬件支持ACE:使用
dma_map_single(dev, addr, size, DMA_BIDIRECTIONAL),由DMA子系统自动处理Cache同步; - 若硬件不支持ACE:必须使用
dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL)分配缓冲区,该函数返回的虚拟地址已绕过CPU Cache,读写直接命中DDR。
- 若硬件支持ACE:使用
用户空间规避技巧:若无法修改驱动(如使用第三方闭源SDK),可在应用层用
mmap()映射/dev/mem到物理地址,并配合__builtin___clear_cache()(GCC内置函数)手动刷Cache。但这属于高危操作,仅限调试阶段。
注意:
dma_alloc_coherent分配的内存有数量限制(通常Linux默认上限为16MB),超出后alloc会失败。此时必须启用IOMMU(如ARM SMMU),它能将非一致性内存通过页表映射为“伪一致性”区域。我们某项目就因忽略此限制,在加载大模型时频繁OOM,最终通过echo 64 > /sys/module/dma_buf/parameters/coherent_pool_size临时扩大池大小解燃眉之急。
3.2 中断与同步机制:别让“快”变成“卡”
协处理器再快,若CPU无法及时获知其状态,整个系统就卡在“等结果”的死循环里。中断设计有三个致命陷阱:
中断风暴(Interrupt Storm):NPU每处理完一帧就发一次中断,当帧率高达120fps时,CPU每秒要响应120次中断,上下文切换开销吞噬全部性能。解决方案是启用中断合并(Interrupt Coalescing):NPU硬件内部计数,累计完成N帧或超时T微秒后才触发一次中断。某FPGA加速卡通过设置
COAL_NUM=8+COAL_TIME=50us,将中断频率压至15Hz,CPU负载从85%降至12%。中断丢失(Lost Interrupt):CPU在
local_irq_disable()临界区内,NPU完成并拉高中断线,但CPU未采样,中断信号被丢弃。根本解法是避免在中断禁用区做耗时操作。我们曾将一段SPI Flash擦除(耗时200ms)放在spin_lock_irqsave保护区内,导致NPU中断永久丢失。修正后,擦除操作移到中断下半部(tasklet)执行,问题消失。虚假中断(Spurious Interrupt):NPU状态寄存器未清零,导致同一中断反复触发。必须在ISR中先读取状态寄存器,再写1清零对应bit。某国产NPU文档写“写0清零”,实测必须写1,文档与硅片不一致——这是芯片原厂的锅,但集成工程师必须背锅。我们建立了一套“中断回归测试用例”,每次新SDK发布,都用逻辑分析仪抓取10万次中断波形,确保无毛刺、无丢失、无重复。
3.3 驱动模型选择:Platform Driver还是PCIe Driver?别被名字骗了
很多工程师看到“协处理器”就条件反射写PCIe驱动,这是巨大误区。PCIe是板级互连标准,适用于独立加速卡;而SoC内部的协处理器(如NPU、VPU)几乎都走AMBA总线,应使用Platform Driver模型。
Platform Driver的核心是platform_device与platform_driver的匹配,通过Device Tree(DTS)描述硬件资源:
npu@12300000 { compatible = "vendor,npu-v2"; reg = <0x0 0x12300000 0x0 0x10000>; // NPU寄存器基址与长度 interrupts = <GIC_SPI 45 IRQ_TYPE_LEVEL_HIGH>; // GIC中断号 dma-ranges = <0x0 0x0 0x0>; // DMA地址映射范围 #address-cells = <2>; #size-cells = <2>; ranges; };驱动中关键代码段:
static int npu_probe(struct platform_device *pdev) { struct resource *res; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); npu_base = devm_ioremap_resource(&pdev->dev, res); // 映射寄存器 irq = platform_get_irq(pdev, 0); request_irq(irq, npu_irq_handler, IRQF_TRIGGER_HIGH, "npu", NULL); // 初始化DMA引擎 dma_dev = &pdev->dev; dma_set_mask(dma_dev, DMA_BIT_MASK(32)); chan = dma_request_chan(dma_dev, "tx"); // 请求TX DMA通道 return 0; }实操心得:Device Tree中
interrupts属性的格式极易出错。ARM GIC中断号计算公式为:GIC_SPI <hwirq_number - 32> <trigger_type>。例如NPU硬件手册写“中断号45”,则DTS中必须写<GIC_SPI 13 IRQ_TYPE_LEVEL_HIGH>(45-32=13)。我们曾因手误写成<GIC_SPI 45 ...>,导致内核启动时中断子系统崩溃,黑屏无日志,排查两天才发现是DTS笔误。
4. 实操过程与核心环节实现
4.1 从零开始:一个完整的NPU集成实操流程(基于Yocto Linux)
我们以某款国产NPU(代号“星火N1”)在i.MX8MQ平台上的集成为例,展示从硬件连接到应用调用的全流程。整个过程分为六个不可跳过的阶段:
阶段1:硬件资源确认与原理图审查(2天)
- 获取NPU Datasheet,确认关键参数:
- 寄存器地址空间(0x32E00000,64KB)
- 中断引脚(GPIO1_IO02,需配置为GIC SPI#45)
- DMA通道(AXI-MM Master Port,支持64-bit宽度)
- 审查原理图:重点检查NPU的
ARESETn是否接至SoC复位控制器;CLK_NPU是否由专用PLL提供且频率锁定在300MHz;VDD_NPU电源纹波是否<10mV(用示波器实测)。
阶段2:Device Tree适配(1天)
在arch/arm64/boot/dts/freescale/imx8mq-evk.dts中添加节点:
&ahb { npu: npu@32e00000 { compatible = "starfire,npu-v1"; reg = <0x0 0x32e00000 0x0 0x10000>; interrupts = <GIC_SPI 13 IRQ_TYPE_LEVEL_HIGH>; clocks = <&clk IMX8MQ_CLK_NPU>; clock-names = "npu_clk"; power-domains = <&pd IMX8MQ_PD_NPU>; #address-cells = <2>; #size-cells = <2>; ranges; dma-ranges = <0x0 0x0 0x0>; dmas = <&sdma2 12 1 0>, <&sdma2 13 1 0>; // TX/RX DMA通道 dma-names = "tx", "rx"; }; };关键动作:
power-domains属性必须正确指向NPU电源域,否则内核在runtime_pm过程中会错误关闭NPU供电。我们曾因此在系统空闲5分钟后NPU无法唤醒,最终在drivers/soc/imx/soc-imx8mq.c中补全了电源域定义。
阶段3:内核驱动开发(5天)
驱动框架采用Platform Driver,核心功能模块:
- 寄存器封装层:
npu_reg.h定义所有寄存器宏,如NPU_CTRL_REG、NPU_STATUS_REG; - DMA引擎适配:重写
npu_dma_submit(),调用dmaengine_submit()提交描述符; - 中断服务程序:
npu_irq_handler()中读取NPU_INT_STATUS,清除bit0(完成中断),唤醒等待队列; - 字符设备接口:
/dev/npu,ioctl()支持NPU_IOCTL_CONFIG(配置模型参数)、NPU_IOCTL_RUN(提交推理任务)。
编译进内核:在drivers/accel/Kconfig中添加config STARFIRE_NPU,Makefile中加入obj-$(CONFIG_STARFIRE_NPU) += starfire_npu.o。
阶段4:用户空间SDK开发(3天)
提供C语言API,隐藏底层细节:
// 初始化NPU int npu_init(int device_id); // 加载模型(二进制权重+结构描述) int npu_load_model(const char* model_path); // 提交推理任务(输入/输出buffer地址、尺寸) int npu_run_task(void* input, void* output, size_t size); // 反初始化 void npu_deinit();关键实现:npu_run_task()内部调用ioctl(fd, NPU_IOCTL_RUN, &task),task结构体包含DMA地址、尺寸、超时时间等。
阶段5:性能调优与稳定性验证(4天)
- DMA带宽测试:用
dd if=/dev/zero of=/dev/npu bs=1M count=1000测持续写入速率,目标≥800MB/s; - 中断延迟测试:用
cyclictest -t1 -p99 -i10000 -l10000测NPU中断响应抖动,P99值<5μs; - 72小时压力测试:循环运行1000次推理,监控
dmesg无DMA timeout、IRQ stuck报错。
阶段6:交付物打包(1天)
- 内核补丁包(
npu-driver-v1.2.patch) - Yocto layer(
meta-starfire-npu)含DTS、驱动、SDK配方 - SDK二进制库(
libnpu.so)与头文件(npu_api.h) - 快速上手指南(含
hello_npu.c示例代码)
整个流程耗时约16人天,其中70%时间花在硬件确认与稳定性验证上——这印证了一个铁律:集成工作的价值,不在代码行数,而在对硬件边界的敬畏。
4.2 关键参数计算:DMA缓冲区大小与描述符队列深度的数学依据
很多工程师凭感觉设DMA缓冲区为1MB、描述符队列为64,这是危险的。科学设定需基于系统吞吐量与实时性约束推导:
步骤1:确定峰值数据吞吐量(Bps)
假设NPU处理1080p@30fps视频,YUV420格式,单帧大小 = 1920×1080×1.5 = 3.11MB,帧率30fps → 峰值吞吐 = 3.11×10⁶ × 30 ≈93.3 MB/s
步骤2:计算最小DMA缓冲区大小
为避免DMA频繁触发中断,缓冲区应至少容纳2帧数据:Buffer_Size ≥ 2 × Frame_Size = 2 × 3.11MB ≈ 6.22MB
向上取整为8MB(对齐页边界,便于dma_alloc_coherent分配)
步骤3:计算描述符队列深度
每个描述符含:源地址(8B)、目的地址(8B)、长度(4B)、控制字(4B)→ 共24B
队列深度N需满足:N × 24B ≤ 缓冲区内存占用
但更重要的是实时性约束:若系统要求端到端延迟<100ms,则队列中最多允许积压100ms × 30fps = 3帧。故N ≤ 3。
然而,为防突发流量,我们设N = 8(支持3帧积压+5帧冗余),描述符内存占用 = 8×24 = 192B,微不足道。
步骤4:验证DMA引擎能力
i.MX8MQ的SDMA2引擎最大突发长度为128×64bit=1024B,而单帧3.11MB需拆分为3.11×10⁶ ÷ 1024 ≈ 3037次突发传输。SDMA2支持最大1024个描述符,满足需求。
这套计算不是纸上谈兵。我们曾因缓冲区设为2MB,在4K@60fps场景下出现DMA溢出(DMA_ERR寄存器置位),系统重启。按公式重算后改为32MB,问题根除。
5. 常见问题与排查技巧实录
5.1 典型故障速查表:从现象反推根因
| 故障现象 | 最可能根因 | 快速验证方法 | 解决方案 |
|---|---|---|---|
| NPU完全无响应,reg read返回0xFFFFFFFF | 1. 电源未上电(VDD_NPU为0V)2. 复位信号持续有效( ARESETn被拉低) | 用万用表测VDD_NPU电压;示波器测ARESETn电平 | 检查PMIC配置;确认SoC复位控制器未将NPU列入复位域 |
| NPU能读寄存器,但写入不生效 | 1. 时钟未使能(CLK_NPU为0Hz)2. 写保护位开启( NPU_CTRL_REG[31]为1) | 逻辑分析仪测CLK_NPU波形;读NPU_CTRL_REG确认bit31 | 在驱动probe()中先写0x80000000解除写保护;检查CCM寄存器使能时钟 |
| DMA传输后,CPU读取输出数据全为0 | 1. Cache未刷新(CPU读取脏Cache) 2. DMA地址映射错误( dma_addr与virt_addr不匹配) | 打印dma_addr与virt_addr;用hexdump直接读DDR物理地址 | 改用dma_alloc_coherent;或手动__clean_dcache_area(virt_addr, size) |
中断偶尔丢失,log显示IRQ 45: nobody cared | 1. ISR中未清除中断状态位 2. 中断触发类型不匹配(硬件为Level-High,DTS设为Edge-Falling) | 读NPU_INT_STATUS寄存器;用示波器测中断引脚波形 | ISR末尾写NPU_INT_CLEAR = 0x1;DTS中改为IRQ_TYPE_LEVEL_HIGH |
| 多任务并发时,推理结果错乱 | 1. 描述符队列未加锁(竞态) 2. NPU未支持多上下文(Context Switch) | 在npu_run_task()前后加printk打点;检查NPU手册Multi-Context章节 | 用spin_lock_irqsave保护队列操作;若NPU不支持多上下文,强制串行化任务提交 |
5.2 独家避坑技巧:那些文档里不会写的实战经验
“热复位”比“冷复位”更可靠:NPU硬件手册常写“上电后需复位”,但实测发现,某些NPU在SoC运行中动态加载驱动时,仅靠
ARESETn脉冲无法清空内部流水线。我们发明了“热复位术”:向NPU的SW_RESET_REG写1触发软复位,再延时10us,写0释放。比硬件复位更可控,已写入所有项目Checklist。DMA地址必须字节对齐,但对齐值要看NPU手册:多数NPU要求DMA地址8字节对齐,但某款国产NPU要求256字节对齐,否则
DMA_ERR。我们曾为此浪费3天,最终在手册第178页小字注释中找到:“TX_ADDR[7:0] must be zero”。教训:NPU手册的“Electrical Characteristics”和“Register Map”章节必须逐字精读,连页脚注释都不能放过。用
devmem2做裸机级调试,比JTAG更快:当内核驱动未就绪时,用devmem2 0x32e00000 w 0x1直接写NPU寄存器,devmem2 0x32e00004 h读状态,5分钟内就能验证硬件连通性。比搭JTAG环境快10倍,是硬件工程师的保命技能。“中断风暴”有隐藏开关:某NPU的中断合并功能默认关闭,需向
INT_COAL_CTRL寄存器写特定magic number(0xDEADBEEF)才能解锁。这个值不在公开手册中,是FAE私下提供的。我们建立了“私有寄存器备忘录”,所有项目组共享,避免重复踩坑。Linux内核版本是隐形杀手:某次将NPU驱动从4.19升级到5.10,
dma_alloc_coherent行为变更,导致原有缓冲区分配失败。根源是5.10引入了CONFIG_DMA_DIRECT_REMAP选项,强制所有coherent内存走IOMMU。解决方案不是降内核,而是make menuconfig中关闭该选项,并在DTS中显式声明iommus = <&smmu 0x1>。
最后分享一个小技巧:每次新NPU集成,我们都会在驱动probe()函数开头插入一段“自检代码”:
// NPU自检:读ID寄存器三次,校验一致性 u32 id1 = readl(npu_base + NPU_ID_REG); u32 id2 = readl(npu_base + NPU_ID_REG); u32 id3 = readl(npu_base + NPU_ID_REG); if (id1 != id2 || id2 != id3) { dev_err(dev, "NPU ID mismatch: 0x%x/0x%x/0x%x\n", id1, id2, id3); return -ENODEV; // 直接退出,不初始化 }这段代码曾帮我们拦截了两起硬件虚焊故障——ID寄存器读取值随机跳变,证明NPU未真正接入总线。与其让系统在后续推理中崩溃,不如在启动瞬间优雅失败。这,就是十年一线工程师刻在骨子里的敬畏。