ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

CCM内存陷阱:以太网DMA通信失败排查实践

CCM内存陷阱:以太网DMA通信失败排查实践 搞嵌入式这些年遇到过不少稀奇古怪的通信问题但最近这次CCM 导致以太网通信失败的排查过程我想专门写出来。原因很简单这类问题太容易踩了而且一旦踩进去它不会像普通 bug 那样报错给你看而是表面上一切正常数据却在一层看不见的墙前面悄悄丢失。如果你手头正好在做带以太网的产品或者项目里用了带紧耦合内存的 MCU这篇内容值得你看完。先说结论问题的根子在于我把以太网 DMA 的描述符和收发缓冲区分配到了一块叫做 CCMCore Coupled Memory中央耦合内存的区域。这块内存只有 CPU 内核能访问以太网 DMA 控制器根本看不见它。于是MAC 寄存器初始化全对、PHY 链接正常、灯也亮但报文就是出不去也进不来。下面把整个排查过程和原理还原一遍。1. 故障现场看起来全都正常实际完全不通1.1 项目背景与硬件配置项目基于一颗带百兆以太网 MAC 的 MCU外挂一颗标准 RMII 接口 PHY 芯片软件协议栈用的官方以太网协议栈这套组合之前在其他板卡上已经跑通过了所以底层驱动的正确性不需要怀疑。新板子回来之后我习惯性地调整了内存分配策略。因为这次需要跑更多的业务逻辑把原来零散分布在普通 SRAM 里的一些大数组统一归类想着把一部分不怎么改动的数据搬进 CCM 区域省出普通 SRAM 空间。这一搬就把以太网的 DMA 描述符数组和发送接收缓冲区也一起搬进了 CCM。当时还觉得自己优化得挺聪明因为 CCM 访问速度快缓冲数据放在里面理论上有性能加成。1.2 通信失败的三种具体表现板子上电后代码跑起来没有任何异常既不触发 HardFault也没有断言失败。但通信上出现了几个典型现象PHY 的寄存器可以通过 MDIO 接口正常读写厂商 ID、状态寄存器值都符合预期。插上网线后PHY 的状态寄存器显示 link upRJ45 绿灯亮起协商速度是 100Mbps 全双工。但从 PC 端 ping 板卡要么完全无响应要么偶尔通一两个包后立刻断开。在协议栈内部打点发现发送队列持续堆积MAC 的 DMA 发送描述符状态始终没有变成已完成接收方向则收不到任何有效的中断计数增长。最迷惑人的地方就在这里你能读到 PHY 的信息说明 MDIO 通路是好的能亮 link 灯说明物理链路是通的协议栈也能初始化说明软件配置没崩。这就像一个水管工把水龙头、阀门、管道都检查了一遍每样东西看起来都是好的但水就是流不到终端。2. 排查链路从 PHY 一路查到内存分配2.1 硬件侧排查示波器确认 RMII 信号第一反应是怀疑硬件。把示波器探针分别点到 MCU 的 RMII_TXD、RMII_TX_EN、RMII_RXD、RMII_CRS_DV 引脚上对比 PHY 侧的时钟50MHz和数据信号。实测下来TX 方向有正常的帧波形说明 CPU 的确把数据从 MAC 侧往外送了但 RX 方向在 ping 包发出后没有对应的回包波形。这个结果说明发送路径至少到了 MAC 的引脚侧问题可能出在更靠近内核的地方。又检查了网络变压器和 RJ45 的接线确认没有接反、没有虚焊。这轮排查排除了物理层送不出去的问题。2.2 软件配置核查寄存器看起来全对回到代码里对照参考手册把 MAC 配置寄存器、DMA 总线模式寄存器、中断使能寄存器全部读出来核对。MACCR 里的 RE接收使能和 TE发送使能都为 1DMA 的软件复位也完成了描述符环指针寄存器 DMA_TX_DTRR、DMA_RX_DTRR 写入的地址看起来也是合法的。这一步很容易让人陷入误区寄存器值是看起来对但硬件真正访问这些地址的时候能不能拿到数据寄存器是看不出来的。这里我犯了一个经验主义错误默认所有 RAM 地址对 DMA 都是可见的于是把大量时间花在了 PHY 配置、中断优先级、协议栈内存池大小这些外围因素上。2.3 决定性的工具调试器查看描述符地址折腾了大半天后决定从底层数据通路入手在调试器里直接查看 DMA 描述符物理地址和内容。这一看问题立刻暴露了。DMA 描述符数组的地址落在0x10000000附近缓冲区地址同样在这个区域。翻出参考手册的内存映射图那一行标注写得非常明确CCM 只能被内核通过紧耦合总线访问不经过总线矩阵外设 DMA 单元不可见。至此真相大白MAC 外设的 DMA 引擎在搬运数据时对这块地址根本没有访问权限总线事务直接失败。发送方向DMA 永远无法从 CCM 描述符里读取待发送数据接收方向DMA 永远无法把收到的数据写入 CCM 缓冲区。于是出现了前面看到的现象MAC 配置正常、PHY 正常但数据通路是断的。3. CCM 的本质为 CPU 服务的专用快车道3.1 紧耦合内存的设计初衷CCM 这类紧耦合内存设计目的是给 CPU 内核一条访问延迟极低的专用通道。它直接挂在内核的总线上不经过系统总线矩阵所以 CPU 访问它时不需要等待总线仲裁比普通 SRAM 快不少。但代价是它只服务 CPU。所有需要通过总线矩阵访问内存的外设包括 DMA、以太网 MAC、USB、SDIO 等都看不到这块区域。可以这么理解普通 SRAM 是城市里的大马路所有车辆都可以走CCM 是小区里的内部通道只有业主CPU能进快递车DMA进不去。3.2 内存区域属性对比实际项目里普通 RAM 和 CCM 的差异可以用下面这个表概括属性维度普通 SRAMCCM紧耦合内存总线上挂载位置总线矩阵内核专用总线CPU 访问支持支持延迟更低外设 DMA 访问支持不支持典型地址段0x20000000 起始0x10000000 起始常见用途全局变量、堆栈、DMA 缓冲区CPU 高频访问变量、关键栈3.3 为什么工程师这么容易踩坑这个坑有几个助推因素缺一个都不至于这么隐蔽第一个是链接脚本的惯性复制。很多项目的链接脚本都是从官方例程或者老项目里拷过来的老项目里没有把数据分配到 CCM新项目改了内存布局之后没有同步审查每个段的属性。链接脚本不会因为你把一个数组放到了 DMA 够不着的地方就报错它只负责分配地址。第二个是内存映射图没有形成肌肉记忆。很多人能背出0x20000000是 SRAM但对0x10000000这个地址段只模糊地知道是另一个 RAM不清楚它的访问边界。等出问题时调试器里看到的地址值并不会让你一眼警觉。第三个是优化内存的心理。CCM 速度快、又空闲很容易让人想把协议栈的收发缓冲挪进去。但网卡驱动这类数据通路上的内存恰恰是对内存属性最敏感的。4. 修复方案让 DMA 回到看得到的普通 SRAM4.1 链接脚本调整把以太网描述符和缓冲区搬回 SRAM定位到问题后修复本身并不复杂。在链接脚本中将以太网 DMA 描述符数组和发送/接收缓冲区强制放置到普通 SRAM 区域。具体到代码只需要把声明这些数组的源文件放到普通 RAM 执行区域即可或者在数组定义上指定__attribute__((section(.sram)))之类的段属性。下面是分散加载文件里常见的修改思路示例; 链接脚本片段示意不同工具链语法不同 ; 修改前以太网缓冲区落在 0x10000000 CCM 区域 ; 修改后显式放到 0x20000000 起始的普通 SRAM #define ETH_RAM_REGION SRAM如果你用的是 GCC 工具链可以直接在数组定义上标注段属性// 以太网 DMA 描述符必须放在 DMA 可见的内存区域 __attribute__((section(.sram), aligned(32))) ETH_DMADescTypeDef eth_tx_desc[ETH_TX_DESC_CNT]; __attribute__((section(.sram), aligned(32))) ETH_DMADescTypeDef eth_rx_desc[ETH_RX_DESC_CNT]; // 收发缓冲区同样放到普通 SRAM __attribute__((section(.sram), aligned(4))) uint8_t eth_tx_buffer[ETH_TX_BUF_SIZE][ETH_TX_BUF_CNT]; __attribute__((section(.sram), aligned(4))) uint8_t eth_rx_buffer[ETH_RX_BUF_SIZE][ETH_RX_BUF_CNT];改完后我再强调一遍aligned(32)这个对齐属性DMA 描述符的地址必须按 32 字节对齐缓冲区按 4 字节对齐具体看芯片要求。如果描述符没有对齐DMA 在访问时会产生地址错误和放到不可见内存区域的后果类似。4.2 验证修复效果与中间细节修改完重新编译烧录上电。这次 ping 包秒回连续跑了几万帧都没有丢包。用打流设备跑了一下吞吐双向都达到了百兆线速。说明数据通路彻底打通了。这个验证过程也提醒我光看通不通还不够最好跑一个持续压力测试确认长时间运行没有偶发问题。因为如果缓冲区只是部分访问越界、或者描述符偶发对齐问题短时间测试可能发现不了。4.3 性能权衡别把 CCM 完全废弃修复之后另一个问题来了CCM 这块高速内存还能不能继续用我的建议是用但要用对地方。CCM 适合放 CPU 频繁访问、但不经过 DMA 的数据。例如协议栈的控制块、会话状态表、一些热点的计数变量、报文管理结构体。这些数据 CPU 每次收发都要读写放在 CCM 里确实能降低总线访问延迟。而 DMA 描述符、收发帧缓冲区这类必须被外设访问的数据一个字都不能放进去。一个安全的分区原则是凡是外设需要直接读写的地址一律放普通 SRAM凡是 CPU 自己高频访问且外设不碰的数据可以放 CCM。5. 这类坑的通用防护不只是以太网其他 DMA 外设也一样5.1 受影响的外设范围以太网 MAC 只是其中一个例子。同样挂在总线矩阵上的 DMA 外设还有很多比如 USB、SDIO、SPI、ADC、定时器的 DMA 请求等。不管哪个外设只要它的 DMA 通道需要访问内存缓冲区这块缓冲区就必须落在 DMA 可见的地址范围内。一个比较典型的连带场景有人做了无操作系统版本的以太网通信跑通了然后上 RTOS把协议栈的缓冲区池通过动态内存分配的方式定义。如果 RTOS 的内存堆被放在了 CCM 区域那么协议栈申请到的 pbuf 全部在 CCM同样会复现这个故障。这类问题甚至更隐蔽因为静态链接脚本看起来没问题问题出在运行时的内存堆位置。5.2 调试时的实用建议结合这次经历我总结了一套针对外设 DMA 异常但寄存器正常类问题的排查习惯第一步先看描述符指针寄存器和缓冲区地址对照参考手册的内存映射表确认落在哪个区域。这一步成本最低收益最高。第二步检查对齐。描述符对齐要求和缓冲区对齐要求不同优先对齐到最大要求。第三步如果地址合法、对齐正确再用调试器在 DMA 搬运的目标/源地址打硬件断点看总线事务是否真正到达了内存。第四步用固定地址数组做二分对照测试。比如用一个固定的普通 SRAM 地址数组作为临时描述符对比 CCM 地址数组一两分钟内就能确认是不是内存属性问题。5.3 项目初期的预防措施最省事的预防是在项目的内存分区表里把 RAM 区域按属性列清楚再把这个表和哪些外设使用 DMA关联起来做评审。每次调整链接脚本都跑一遍所有外设的持续压力测试而不是只跑功能测试。另外可以给链接脚本加一个地址范围检查在启动代码里对所有 DMA 相关数组做一个编译期断言确保地址落在0x20000000到普通 SRAM 结束范围内。比如用静态断言检查地址值// 编译期断言确保描述符地址在 DMA 可见范围 _Static_assert((uint32_t)eth_tx_desc 0x20000000, eth_tx_desc must be in SRAM); _Static_assert((uint32_t)eth_rx_buf 0x20000000, eth_rx_buf must be in SRAM);这样做虽然不能覆盖所有运行时动态分配的情况但至少静态数组定义能提前暴露问题。这种问题最大的迷惑性在于表面全对PHY 通讯正常、link 灯正常、寄存器配置正常、协议栈初始化正常唯独数据通路在最底层无声无息地断掉。我个人的调试体会是遇到这类诡异外设问题第一件事就去盯 DMA 描述符地址落在哪个内存区域别在寄存器配置上反复打转。把这个习惯养成之后后面再做带 USB 或 SD 卡的项目也能少走一大段弯路。
返回列表