Linux PCIe驱动调试全解析:从枚举原理到实战排障 做Linux驱动开发这些年PCIe驱动调试算是绕不开的硬骨头。新来的同事常问我为什么设备在机器上明明插着系统就是不识别为什么BAR一读就死机为什么DMA回传的数据全是0这些问题看着天马行空其实背后都指向同一套PCIe底层机制。这篇文章我把PCIe驱动调试从原理到实操完整拆一遍先讲枚举和配置空间的底层逻辑再给出环境准备和工具链清单接着列出驱动代码里最容易出问题的几个位点最后用一次完整的“某数据采集卡”排障过程串起整个方法。适合刚接触设备驱动开发的人也适合被PCIe问题卡了好几天、想找排查突破口的老手内容基本都能直接搬到现场用。1. 调试前必须搞懂的几个底层机制1.1 枚举与配置空间驱动和硬件握手的起点PCIe设备在上电后不是立即可用的它要经过一个从“物理层训练”到“软件发现”的完整过程。物理层面设备复位完成后会进行链路训练协商速率2.5GT/s、5GT/s、8GT/s等和宽度x1/x4/x8/x16这一步搞不定后面所有软件操作都无从谈起。链路训练成功后根复合体Root Complex开始扫描总线给每个设备分配一套编号格式就是大家熟悉的“总线号:设备号.功能号”缩写为BDF。扫描过程中软件通过配置读写事务读取设备的配置空间把厂商ID、设备ID、类别码、BAR等信息登记到系统里。这个阶段如果出问题最典型的现象就是lspci里根本看不到设备。很多驱动工程师一上来就翻驱动代码实际上驱动压根没机会执行——设备在枚举阶段就被“丢”了。所以我的习惯是任何PCIe问题先确认lspci能看到设备再谈后续。看不到设备说明链路训练失败、复位信号没给对、参考时钟缺失或者供电不足这些属于硬件和板级问题驱动层帮不上忙。配置空间的前64字节是标准头部包含Vendor ID、Device ID、Class Code、Revision ID、Command/Status寄存器以及6个BAR寄存器。这64字节是驱动与硬件握手的第一张名片。之后的“能力链表”Capability List才是PCIe特色功能的藏身处——MSI/MSI-X能力、电源管理能力、AER错误报告能力、链路状态能力都挂在这个链表上。调试时不要只盯着头部看很多怪问题都藏在能力链表的某个字段里。1.2 五种典型调试需求与它们的表面症状我在实际项目里遇到的PCIe调试需求归结起来就五类每一类都有非常固定的外在表现。提前把这些症状记熟能少走一半弯路。第一类是设备枚举失败或者枚举到了但ID不对。现象是系统起来后lspci看不到设备偶尔看到但Vendor ID是ffff或者Class Code明显不对。这类问题的根子通常在硬件链路、配置空间读取时序、上电复位顺序上。第二类是BAR空间映射异常。设备在lspci里能看到但一旦访问BAR对应的MMIO地址系统就卡死、触发总线错误或者读到全ff。这类多半是BAR大小声明有误、64位BAR的高32位没配置或者物理地址被其他设备占用了。第三类是DMA异常。典型表现是设备报告传输完成但CPU读回的数据不是全0就是错乱。这类问题跟IOMMU页表、地址转换、TLP的地址域标记关系最大。第四类是中断异常。要么中断完全不触发要么触发一次之后整个系统像死了一样要么中断风暴把某个CPU核心占满。问题往往出在中断向量配置、中断状态寄存器没清除、共享中断处理不严谨这几个地方。第五类是性能不达预期。带宽跑不满、延迟抖动大、长时间运行后吞吐逐步下降。这类问题要查链路状态、ASPM电源管理策略、DMA描述符链的处理效率。这五类问题看起来各自独立但在实际调试中经常串在一起。我见过一个设备先是BAR映射的物理地址和别的设备冲突勉强改掉之后DMA又报错最后发现是同一个寄存器配置错误导致的所有症状。这就是为什么要先搞懂机制再动手改代码。2. 把调试环境调到“话痨模式”2.1 内核编译选项信息量决定排查速度内核默认的PCIe日志其实相当克制很多关键执行路径只在debug级别输出。所以调试前第一件事是确认内核打开了CONFIG_PCI_DEBUG。这个选项开启后会输出大量PCIe子系统的内部日志包括枚举过程、资源分配过程、BAR分配是否成功、电源管理状态切换等。没有这些日志你只能靠lspci的结果去反推系统内部发生了什么效率差太多。除了CONFIG_PCI_DEBUGCONFIG_DYNAMIC_DEBUG也建议打开。有了它可以在运行时按模块或者文件动态打开调试输出不用重新编译内核。调试某一款驱动时我最常用的是echo file drivers/pci/* p /sys/kernel/debug/dynamic_debug/control这一行会把PCIe核心层里所有dev_dbg级别的信息全部打开配合dmesg -w实时观察。实测下来BAR分配失败、资源冲突、能力注册出错这类问题会立刻现出原形。启动参数方面pcidebug这个参数会把PCIe子系统的日志级别强制拉到最详细pcirealloc允许系统在启动时重新分配BAR资源pciassign-busses用于强制重新枚举总线拓扑。一次性多给上几个参数也不冲突调试阶段宁滥勿缺pcidebug realloc assign-busses要注意的是pcirealloc在量产阶段要慎用因为它可能改变原有的资源分配布局导致老系统上出现资源冲突。但调试阶段非常有用很多BAR空间分配失败的问题加上它之后直接就能过。2.2 用户态三板斧lspci、setpci、devmemlspci是看得最频繁的工具没有之一。基础用法是lspci -vvv查看所有设备的详细状态包括链路协商速度、宽度、BAR地址范围、中断线、MSI能力配置等。调试链路问题时要重点看LnkSta字段它会明确显示当前协商速率和宽度。比如你是x4的卡但LnkSta显示Width x1, Speed 2.5GT/s说明链路退化到了最保守状态先查物理连接和参考时钟别动驱动代码。setpci用来直接读写配置空间。注意setpci操作的是配置空间不是BAR空间。它的典型用法是修改Command寄存器比如关闭设备响应、开启/关闭总线宽度# 查看设备Command寄存器 setpci -s 03:00.0 COMMAND # 写入Command寄存器例如开启MEM空间访问和Bus Master setpci -s 03:00.0 COMMAND0x0007对驱动开发者来说setpci最大的价值是复现问题改掉某个配置空间字段观察设备行为变化快速定位是哪个能力位导致了故障。devmem用来直接访问物理地址也就是BAR映射到的那段地址。很多新手在这里犯迷糊——devmem访问的是物理地址不是虚拟地址。你可以先通过lspci -vvv看到BAR的物理起始地址然后devmem读这个地址。如果设备BAR对应物理地址是0xfb000000可以这样读devmem 0xfb000000 32这个操作会直接发起一次MMIO读事务如果设备没有正确响应轻则读到全ff重则总线挂死。所以每次用devmem测BAR之前我都会先确认地址确实来自lspci的BAR输出而不是随便拿个地址去试。2.3 内核侧进阶ftrace与PCIe事件追踪用户态工具能看结果但看不到驱动执行路径里到底发生了什么。这时就要靠内核侧的事件追踪。ftrace是首选因为它几乎不需要额外配置内核自带的tracefs挂载点开启后就能用。我常用的操作是把PCIe子系统和驱动模块的函数调用打出来# 挂载tracefs mount -t tracefs tracefs /sys/kernel/tracing # 跟踪指定模块的函数 echo function /sys/kernel/tracing/current_tracer echo my_pcie_driver:probe /sys/kernel/tracing/set_ftrace_filter echo 1 /sys/kernel/tracing/tracing_on cat /sys/kernel/tracing/trace这样能看到驱动probe函数内部的具体调用顺序比如pci_enable_device是在哪个节点被调用、返回了什么、有没有提前被-ENODEV打断。内核还提供了PCIe相关的tracepoint比如pcie_event、aer_event等。它们比函数级跟踪开销小得多适合在长时间运行场景下观察零星发生的错误。开启方法是在tracefs的events目录下找到对应事件然后使能。用tracepoint查AER错误特别有效能抓到发生错误时的具体设备BDF、错误类型和状态寄存器值。我个人的经验是静态代码看逻辑lspci看结果ftrace看路径tracepoint看事件。这四个维度配合起来基本可以覆盖95%的PCIe驱动调试场景。3. 驱动代码里的关键调试位点3.1 probe函数里的“体检清单”驱动入口函数是调试主战场。我写过的PCIe驱动虽然用途各异但probe函数里总会先做一套固定“体检”这套流程帮我挡掉过大量低级问题。核心顺序是使能设备、请求资源、分配DMA掩码、映射BAR、设置中断、最后才是初始化设备内部状态。关键代码框架大致长这样static int my_pcie_probe(struct pci_dev *pdev, const struct pci_device_id *id) { int ret; phys_addr_t bar_start; size_t bar_len; /* 1. 使能设备打开memory space、I/O space和bus master */ ret pci_enable_device(pdev); if (ret) return ret; /* 2. 请求设备占用的资源区间防止被别的驱动误用 */ ret pci_request_regions(pdev, my_pcie_driver); if (ret) { pci_disable_device(pdev); return ret; } /* 3. 设置DMA掩码先试64位不行再退回32位 */ ret dma_set_mask_and_coherent(pdev-dev, DMA_BIT_MASK(64)); if (ret) { ret dma_set_mask_and_coherent(pdev-dev, DMA_BIT_MASK(32)); if (ret) { pci_release_regions(pdev); pci_disable_device(pdev); return ret; } } /* 4. 检查BAR0的大小和类型确认硬件匹配 */ bar_start pci_resource_start(pdev, 0); bar_len pci_resource_len(pdev, 0); if (bar_len 4096) { dev_err(pdev-dev, BAR0 too small: %zu\n, bar_len); ret -ENODEV; goto err_release; } /* 5. 映射BAR0到内核虚拟地址 */ void __iomem *bar0 pci_iomap(pdev, 0, bar_len); if (!bar0) { ret -ENOMEM; goto err_release; } /* 6. 对映射后的地址做一次写读回验证 */ writel(0xA5A5A5A5, bar0); if (readl(bar0) ! 0xA5A5A5A5) { dev_err(pdev-dev, BAR0 write-readback failed\n); ret -EIO; goto err_unmap; } /* 7. 中断配置尽量用MSI/MSI-X回退到INTx */ ret pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI); if (ret 0) { ret pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_INTx); if (ret 0) goto err_unmap; } /* 8. 注册中断处理函数 */ ret request_irq(pci_irq_vector(pdev, 0), my_irq_handler, 0, my_pcie_driver, pdev); if (ret) goto err_vectors; /* 9. 中断处理里需要的回调参数和数据缓冲区在这里申请 */ /* ... 后续业务初始化 ... */ return 0; err_vectors: pci_free_irq_vectors(pdev); err_unmap: pci_iounmap(pdev, bar0); err_release: pci_release_regions(pdev); pci_disable_device(pdev); return ret; }这一段代码里第6步的“写读回验证”是最容易被忽略的。很多驱动只做了pci_iomap就认为BAR访问没问题等业务代码跑起来才发现读写全无效。如果设备支持写上0xA5A5A5A5再读出来对比能在30秒内确认BAR通路是否正常。这个验证跑不通后面所有DMA和中断调试都是浪费时间。另一个常被忽略的点是dma_set_mask_and_coherent的调用时机。必须在申请DMA缓冲区之前设置好掩码否则后续的DMA地址分配可能落在设备无法访问的物理地址范围内。设备如果只支持32位DMA而掩码没设置申请出来的缓冲区可能在4GB以上设备访问时地址被截断数据错乱就是必然的。3.2 中断调试的两种典型误区中断配置是PCIe驱动里最容易“看起来正常但实际故障”的部分。第一种误区发生在INTx共享中断上。PCIe允许旧式中断共享一条IRQ线request_irq时必须指定IRQF_SHARED标志否则申请会失败。但共享中断有个硬性要求中断处理函数一进来首先要读自己设备的中断状态寄存器确认是不是自己的设备触发了中断如果不是必须立刻返回IRQ_NONE绝对不能擅自清中断或者做业务处理。我调试过的一个网卡驱动项目里设备A和设备B共享一条INTx。设备A的中断处理函数没做“先确认再处理”每次只要设备B触发中断A的中断函数也执行一遍把A设备的中断状态给清了。结果设备B的中断永远得不到正确处理网络偶发断流。这个问题整整排查了两天最后靠在中断函数里加一行dev_info打印才发现是共享中断的锅。第二种误区是MSI/MSI-X配置的中断向量不匹配。pci_alloc_irq_vectors返回的的向量数可能比你申请的少设备实际发送的中断向量也可能和驱动申请的对不上。判断是否匹配一个是看设备侧的能力寄存器里MSI-X Table的配置另一个是在中断处理函数里用pci_irq_vector反向核对。如果设备的中断被路由到了别的CPU核心上处理函数进不来你就只能看到中断计数在增长但业务逻辑毫无反应。还有一类中断风暴问题症状是某个CPU核心使用率100%/proc/interrupts里对应中断次数飞涨。常见原因是中断处理函数里没清设备的中断状态位设备一直拉高中断信号CPU反复进入中断处理。这时候别急着优化代码先在中断入口加个计数器配合disable_irq_nosync中断当前设备的IRQ观察计数是停了还是继续涨。停了就说明是设备一直在发中断检查清状态位的逻辑没停就说明中断源不是这个设备回头查共享中断和IRQ路由配置。3.3 DMA映射的三种常见翻车DMA是PCIe驱动最核心的功能也是出错花样最多的地方。在我看来DMA调试问题九成以上出在地址转换的理解上。CPU访问设备用虚拟地址设备访问内存用的是总线地址这两者之间隔了一层IOMMU如果开启的话。驱动代码里必须用DMA API的返回值作为设备看到的地址而不是直接把内核虚拟地址或物理地址传过去。常见的翻车姿势有三种。第一种是把kmalloc出来的内核虚拟地址直接传给设备。在未开启IOMMU的简单系统上偶尔能工作但只要IOMMU一开设备拿这个虚拟地址去发起DMAIOMMU页表根本找不到对应的映射要么DMA超时要么产生translation fault。第二种是用了virt_to_phys拿到物理地址后传给设备但没考虑IOMMU的存在设备发起DMA时地址再次被IOMMU转换结果访问到了错误的物理内存。第三种是申请缓冲区后忘记调用dma_map_single或者dma_alloc_coherent直接使用原始地址做DMA。正确的做法分两类。控制类的小块内存用一致性DMAdma_addr_t dma_handle; void *cpu_addr; cpu_addr dma_alloc_coherent(pdev-dev, size, dma_handle, GFP_KERNEL); // 设备使用dma_handle作为DMA地址驱动使用cpu_addr访问内容大块流式数据传输用流式DMA映射dma_addr_t bus_addr; bus_addr dma_map_single(pdev-dev, cpu_virt_addr, len, DMA_TO_DEVICE); // 把bus_addr下发给设备 // 传输完成后 dma_unmap_single(pdev-dev, bus_addr, len, DMA_TO_DEVICE);调试DMA问题时一个非常实用的手段是暂时关闭IOMMU和SMMU如果是ARM平台做对照实验。如果关掉IOMMU后DMA正常、开启就异常那基本可以肯定是地址转换环节的问题。这时回头查代码看下发给设备的地址到底是不是dma_map_*的返回值而不是virt_to_phys的返回值。4. 完整排障实录某数据采集卡从零到跑通4.1 第一阶段设备根本不识别某次项目里拿到一块自研的数据采集卡插到测试服务器上开机后lspci完全没有它的身影。我第一反应不是去翻驱动代码而是先确认设备是否完成了链路训练。板卡是由FPGA实现的PCIe端点之前没有上过机硬件组只验证了单板供电链路没做过系统级联调。这种状态下的问题大概率在上电时序和参考时钟。排查路径是先看电源用万用表量PCIe插槽的3.3V和12V辅助供电确认在位。然后看复位信号PCIe的PERST#信号在系统上电后应该拉高释放如果一直拉低端点设备会一直处于复位状态根本不会参与链路训练。最后用示波器看参考时钟100MHz差分时钟要稳定输出频率偏一点都会导致链路训练失败。结果问题就出在参考时钟上。设备板卡上的参考时钟走线虚焊导致一端没有时钟信号。重新焊接后上电再跑lspci设备正常出现。这个阶段的教训是设备不识别先给硬件排查留出时间不要急着打开驱动工程。没有链路训练驱动写一万行也没用。4.2 第二阶段BAR一碰就“咬死”设备能被lspci看到了但驱动加载后probe函数里执行到BAR写读回验证时主板直接死机。devmem读BAR地址同样会卡死。这个症状在服务器上表现为整机挂起只能断电重启。我先把lspci -vvv的输出完整翻了一遍发现BAR0的地址范围显示的是64位地址但设备实际只有低32位地址线高32位地址解码没有实现。这就导致驱动按64位BAR访问时产生的MMIO事务设备解析出来的地址完全错乱挂死总线是必然的。问题的根源是FPGA里的PCIe硬核配置里BAR地址宽度设置不对。修复方式是重新配置FPGA逻辑让BAR0声明为32位同时把BAR1清成0避免系统误判为64位BAR。重新烧录后lspci显示BAR0的地址范围恢复成32位驱动里pci_resource_len返回的大小也变正常了。这个阶段让我意识到BAR的宽度声明必须和硬件实际地址解码位宽一致写驱动前先看FPGA里的寄存器配置能省掉一整天的死机调试。4.3 第三阶段DMA回传的数据全是0BAR通路打通后驱动可以正常读写设备内部寄存器了。接着做DMA回环测试设备从DMA缓冲区读一段数据然后写回到另一块缓冲区驱动比较两块内容是否一致。结果设备报告DMA传输成功但回写缓冲区里的内容全是0。我先在驱动里把DMA缓冲区的CPU虚拟地址、物理地址、DMA映射后的总线地址全部打印出来逐一核对。发现传给设备的地址用的是virt_to_phys得到的物理地址而不是dma_map_single返回的DMA地址。测试服务器上开着IOMMU设备拿物理地址去发起DMA经过IOMMU转换后访问的完全不是驱动预期的内存区域。设备“成功”写到了某处但驱动从映射缓冲区里读到的自然是全0。修复很简单把驱动里下发地址的代码改用dma_map_single的返回值。改完再跑数据一致测试通过。这个案例是典型的“驱动工程师照搬网上旧代码没考虑IOMMU影响”的翻车。建议大家在项目里统一用DMA API处理地址凡是virt_to_phys直接传给设备的写法当成代码评审里的一票否决项。4.4 第四阶段中断触发一次就整个卡死DMA通了之后做中断验证发现中断处理函数第一次进入后执行不到几行系统就完全卡住连控制台都没反应。第一个怀疑点是中断处理里做了重活。但一套测试逻辑很短只有状态检查和数据搬运不至于卡死系统。翻看代码问题出在中断处理函数里对一个寄存器做了“读-修改-写”操作而这个寄存器在设备侧是“写1清0”的清除寄存器。实际采集卡在FPGA里实现时把状态寄存器的清除逻辑做成了“读到1就清”和PCIe约定俗成的“写1清0”不一致。中断函数读状态的操作顺手把状态位清掉了设备认为中断已经被处理不再拉高中断信号但驱动侧的循环还没跑完等待下一个中断永远不来业务逻辑死等导致系统假死。修复方式是让中断处理函数只读状态、不做任何“写”操作清状态的动作单独放到业务线程里通过寄存器写入明确写入1再清除。改完后再测连续触发1000次中断系统稳定中断响应时间也符合预期。这个阶段还得到一个额外经验FPGA队友对PCIe寄存器的理解经常和驱动预期不一致最好的办法是在联调前把寄存器定义表过一遍明确每个寄存器的读写语义和清除语义。5. 常见问题与排查技巧速查表问题现象可能原因优先排查手段解决思路lspci看不到设备链路训练失败、复位未释放、参考时钟缺失量PERST#、时钟、电源检查LnkSta先排除板级问题再动软件BAR访问导致系统挂死BAR宽度声明与实际不符、地址冲突lspci -vvv核对BAR类型和范围调整FPGA配置确认BAR宽度MMIO读全为ff设备未完成配置、链路进入低功耗态检查Command寄存器、LnkSta确认MEM空间访问已开启尝试setpci操作DMA回传全0IOMMU地址转换错误、DMA地址来源不对打印dma_map返回地址和CPU地址统一使用DMA API地址不开IOMMU对照实验中断进不来中断未使能、MSI向量配置错误、共享中断误认看/proc/interrupts、检查能力寄存器确认pci_alloc_irq_vectors方式和设备配置匹配中断风暴中断状态未清除、设备持续拉高信号加计数、临时disable_irq_nosync修正中断状态清除逻辑吞吐不达预期链路退化为低速或窄宽、ASPM策略检查LnkSta字段、查看电源状态调整ASPM策略、确认物理链路连接长时间运行后偶发报错AER错误未被处理、电源状态切换异常开启AER tracepoint、看dmesg依据AER错误码定位链路问题这张表是我排查问题时的默认索引。每次接到新的PCIe故障先对着表把“可能原因”列过一遍多数问题能缩小到一到两个方向再针对性查日志和寄存器状态基本不会跑偏。再分享两个额外的心得。第一个不要迷信设备手册尤其是寄存器语义描述模糊的手册要以硬件联调时的实测结果为准。手册说“写1清0”的设备实测可能完全不同尽早用写读回验证建立“寄存器行为基线”比事后翻手册高效得多。第二个中断处理函数只用dev_info级别的窄打印做临时观察确认后立刻摘掉。中断频率高时一次打印的开销足以让系统假死反而不利于定位问题。6. 性能调优与稳定性的几个“后置动作”驱动跑通只是第一步离交付还差性能验证和长时间稳定性。我调试的采集卡在功能测试全部通过后跑到第三个小时出现偶发超时dmesg里刷出了PCIe AER错误记录。AER状态寄存器指向一次URUnsupported Request错误。查来查去发现是设备访问了一个未实现的配置空间寄存器TLP被上游交换机直接打回生成了UR错误。这种错误平时不致命但次数多了会导致链路重训重训期间业务中断超时就是重训造成的。处理方式是让设备驱动避免访问那些保留寄存器同时在FPGA逻辑里把未实现寄存器的访问改为返回“全0”而不是“不响应”从源头上减少错误TLP的产生。开启AER上报也非常重要确认内核打开了CONFIG_PCIEAER这样一旦出现错误系统会第一时间通过日志告诉我们具体哪个BDF、哪类错误不用靠猜。ASPM主动电源管理是另一个需要研究的点。服务器默认策略下链路空闲时会降低速率或者进入节能状态但自研设备对链路唤醒的延迟处理往往不够完善导致每次唤醒都会增加几十微秒的延迟高频小数据包场景下吞吐明显受损。排查时可以先用启动参数关掉ASPM做个对照pcie_aspmoff如果关闭后性能显著改善再做细粒度配置。例如在设备驱动里通过pci_disable_link_state主动关闭对应链路的ASPM状态保持链路全速运行同时不影响整机其他设备的电源管理。还有一项容易被忽略但很影响稳定性的细节是MMIO访问的编译语义。访问BAR映射区域的寄存器必须使用readl、writel这一组IO访问函数。直接对__iomem指针做C语言解引用编译器在某些优化等级下会把多次读操作合并成一次或者把写操作重排设备看到的事务顺序错乱轻则功能异常重则死机。这个坑在新手驱动里出现概率极高。长时间压测也是必须的一步。我推荐至少做三件事第一DMA环形缓冲区在满负荷下持续跑24小时观察有无数据错位第二在中断密集场景下用perf top观察CPU开销确认中断处理没有跑出预期范围第三反复做设备热插拔如果是支持热插拔的槽位和驱动模块加载/卸载循环确认probe和remove路径没有资源泄漏。这些动作做完设备才敢真正交到用户手里。最后说一点点个人经验PCIe驱动调试做到后来拼的不是技巧而是对“数据到底沿着哪条路径走”的理解。比如中断进不来你要想清楚中断是从设备发出、经过链路、到达中断控制器、再分发到CPU的哪一步断了DMA数据错了你要想清楚数据从设备发到内存经过IOMMU转换后写到了哪块物理地址。路径上每一步都有对应的工具去观察lspci看链路状态devmem看MMIO读写/proc/interrupts看中断到达内核日志看DMA事件。把路径上的每一步都确认一遍问题就会自己浮出来。我自己的习惯是准备一个Excel表把每个调试阶段的“现象、日志、寄存器值、结论”记录下来。PCIe问题经常是多个因素叠加记录能帮你发现之前遗漏的关联线索。串起整条调试链路后你会发现那些看起来神秘莫测的PCIe故障在真正理解机制的工程师面前只是纸老虎。