ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式Linux平台Memtest移植与DDR稳定性测试实战

嵌入式Linux平台Memtest移植与DDR稳定性测试实战

干嵌入式Linux开发的朋友,多少都遇到过这种场面:产品在老化架上连续跑了几十个小时,偶发一次重启;或者客户现场设备每天固定时间死机,抓log什么都看不到,查了好几天也定位不出来。这种问题最麻烦的一点,就是它不像逻辑bug那样每次必现,而是要撞运气。排查到最后,十有八九会把目光落到DDR上。

这次我接的任务就是把Memtest移植到海思平台上,专门用来测DDR稳定性。芯片是某个海思系列方案,系统是SDK配套的标准Linux环境。说白了,就是在已经有内核和文件系统的前提下,把一个跑在用户态的内存测试工具交叉编译出来,塞进板子里,用各种数据pattern反复读写DDR,把潜在的硬件缺陷、布线问题、时序余量不足这些隐藏坑都逼出来。

这篇文章适合两类人看:一类是做嵌入式Linux BSP或者产品开发,需要验证内存稳定性的工程师;另一类是做硬件测试、量产老化的人,需要一套能自动跑、能留证据的DDR测试手段。读完你基本能完整复现整个流程,从选工具、交叉编译,到部署执行、解读结果,再到常见坑的排查,一次性讲清楚。

1. 为什么非要做DDR测试,以及为什么是Memtest

1.1 DDR故障在嵌入式设备里的典型表现

DDR出问题的时候通常不会直接弹窗告诉你“内存故障”,它更像是系统里的慢性病。最典型的几个表现是:设备偶发死机或重启、视频画面花屏或绿屏、文件系统CRC校验报错、进程随机崩溃。尤其是那些7x24小时运行的摄像头和边缘盒子,可能开机几天都正常,一到深夜某个温度区间就突然挂掉,整个现场毫无规律可循。

为什么会这样?DDR承担了整个系统运行时数据的主要存储,内核、进程堆栈、DMA缓冲、文件缓存全都堆在里面。DRAM存储单元是靠电容电荷保持数据的,电荷会随着温度和时间慢慢泄漏,如果刷新不及时、时序余量不足,某个cell就会发生翻转;另外DDR走线在PCB上的长度匹配、参考电压不稳、相邻信号串扰,也会让数据在传输过程中出错。这类问题在常温下可能完全测不出来,但温度一升高、频率一拉高,马上原形毕露。

我常用一个类比:DDR就像人的短期记忆,整个系统所有程序正在处理的数据都临时放在那里。短期记忆偶尔丢失一条信息,表现可能就是程序“突然忘了自己刚才在干什么”,然后死循环、崩溃、重启,毫无规律。要找到这种“记忆差错”的根源,就要靠内存测试工具一轮一轮地读写法,用各种数据组合去刺激它出错。

1.2 用户态memtester的工作原理与选型理由

Memtest这个称呼其实是一类工具的统称,Linux下面最常见的有memtest86+和memtester。这次要跑在海思的嵌入式Linux里,我选的是memtester,主要原因是它轻量、跨平台容易编译、不需要重启机器就能跑。

memtester的原理相当朴素:先在用户态申请一块内存,然后按顺序向这块内存写入不同的pattern数据,过一段时间再读回来逐字节比较,同时通过加减乘除、与或、位翻转之类的操作去检测数据通路的稳定性。如果写入的值和读回来的值不一致,就说明存数据的那块物理内存出了问题。测试跑的时间越长、pattern种类越丰富,能覆盖到的内存cell组合和地址线组合就越多,问题暴露的概率也越大。

选它还有几个实际理由。第一,它可以在系统运行状态下测,不用反复重启板子,这对快速定位问题非常重要。第二,测试内存大小和迭代次数都可以用命令行参数控制,适合脚本化、自动化。第三,结果直接输出到stdout,测试log很容易被老化脚本抓取和保存。第四,源码本身依赖很少,只有一个tar包加标准libc,几乎任何Linux平台都能编译出来,做交叉移植最省心。

1.3 和bootloader阶段mtest的定位差别

可能有人会问:海思平台的bootloader里不是自带内存测试命令吗?确实有,很多方案在U-Boot下敲mtest就能对指定地址区间做简单的读写测试。但它和memtester定位不一样。

bootloader阶段的测试是在DDR控制器刚初始化完成、内核还没跑起来的时候做的,好处是环境干净,能直接测DDR底层读写;缺点是pattern太少,测试方法比较简单,而且测不了系统运行状态下内存控制器与DMA、总线、缓存协同工作的情况。memtester跑在Linux用户态,测试的是“整个系统实际运行状态”下的内存,更接近产品真实工作场景,还能叠加业务负载一起压。所以我的习惯是把两者结合起来:底层硬件摸底用U-Boot的mtest,系统层面的稳定性验证用memtester,两边都过才敢放心放量。

2. 移植前的准备:先搞清平台,再谈编译

2.1 确认目标平台的架构与工具链

很多人一上来就急着解压源码、敲make,结果编出来的二进制在板子上跑不了,报Exec format error或者直接Illegal instruction。其实移植这种事,动手之前最该做的是先把目标平台的底细摸清楚。

在海思板子上跑uname -m看一眼内核架构,如果输出armv7l就是32位ARM,如果输出aarch64就是64位ARM。这决定着你该挑哪个交叉编译器。海思SDK一般都会自带一套工具链,命名通常和版本强相关,常见的前缀有arm-himix100-linux-、arm-hisi-linux-,64位方案则是aarch64-himix100-linux-之类的。关键是别用宿主机的x86 gcc去编,也别拿32位的工具链去编64位内核上的程序,否则后面全是坑。

另一个容易被忽视的点是C库的匹配。同一个ARM架构,glibc编译的程序和musl编译的程序在运行库依赖上完全不同。具体到海思方案,SDK里默认环境大多是glibc,所以我直接用配套工具链编glibc版本,没去折腾musl。假如你手上的目标系统是精简rootfs,动态库没装全,那就得考虑静态编译,这个我后面细说。

2.2 静态编译还是动态编译:嵌入式环境的取舍

嵌入式设备的rootfs通常都很紧凑,动态编译的二进制只有几十KB,听着很诱人,但它有一个隐藏成本:目标板上必须有配套的C动态库,比如libc.so.6、ld-linux-armhf.so.3,版本还必须匹配,否则一运行就报No such file or directory,看着像文件不存在,其实是动态链接器找不到。

我在这类移植场景里默认先做静态编译。静态编译出来的memtester体积会大一些,但也就几百KB级别,对于容量以MB甚至GB计算的rootfs来说根本不算事。而且静态版本丢到任意同架构的板子上都能直接跑,不用管目标板C库齐不齐,省掉一堆麻烦。海思SDK自带的工具链通常都带静态库,直接加-static就能编;万一你的工具链没装libc.a,再退回动态编译,部署时注意把对应的动态库一起带上就行。

2.3 源码里值得先看的几个地方

解压memtester源码包之后,别直接make,先花两分钟翻一翻几个关键文件,能省后面调试的时间。

Makefile里C编译选项默认有一堆POSIX相关的宏定义,不用改。但如果你想在测试期间把内存锁定在物理内存里,防止被内核换出,可以在CFLAGS里加-DRLIMIT_MEMLOCK,它会让程序调用mlockall把测试页锁住,让DDR测试结果更可靠。嵌入式Linux一般没有swap,这个选项加不加差异不大,但加上更稳妥。

还有types.h里定义了一些和平台相关的整数类型和内存块上限。默认配置在32位ARM下测几百MB内存没问题,但如果你的板子内存特别大,想一次性测超大块,就得留意MAX_*这类宏的上限是否够用。有次我在一个1GB内存的平台上想直接测800MB,结果程序自己受限分配不出来,后来把上限宏调大才解决。这个坑虽然不常见,但知道在哪,遇到的时候就不会懵。

3. 交叉编译实操:从源码到能跑的ARM二进制

3.1 下载源码与实际编译命令

交叉编译memtester本身不复杂,核心就是把CC指定成目标平台的交叉编译器。先下载源码解压,然后进入目录执行make,不带-j也无所谓,这个工程文件不多,编译非常快。

以32位海思方案为例,如果SDK的环境变量已经source好了,命令就是:

tar xf memtester-4.5.1.tar.gz cd memtester-4.5.1 make CC=arm-himix100-linux-gcc CFLAGS="-O2 -static -DRLIMIT_MEMLOCK"

如果环境变量没source,就用完整路径:

make CC=/opt/arm-himix100-linux/bin/arm-himix100-linux-gcc CFLAGS="-O2 -static -DRLIMIT_MEMLOCK"

64位平台同理,把前缀换成对应的aarch64-xxx-gcc即可。编完之后目录下会生成一个memtester可执行文件。

这里多说一句,网上有些教程会让你去改源码里的Makefile才能指定编译器,其实没必要。直接通过make命令行传CC变量就能覆盖Makefile里的默认值,不污染原始文件,下次再编也方便改参数。如果后续需要调整编译选项,改命令行里的CFLAGS就够了。

3.2 验证二进制可用性的三步检查

编译完不能急着往板子里塞,先在本机做三个检查,花不了两分钟,但能拦截掉绝大多数低级错误。

第一步,用file命令看可执行文件格式:

file memtester

正常的32位ARM静态版本会输出类似ELF 32-bit LSB executable, ARM, statically linked这样的信息。如果看到x86-64或者Intel 80386,说明编译器选错了,赶紧回去检查CC变量。如果你的工具链是ARMv7硬浮点的,编译选项里别少了浮点ABI的配置,否则在板子上可能跑出Illegal instruction。

第二步,用readelf查看动态链接依赖:

readelf -d memtester | head -20

如果输出里没有NEEDED项,说明是静态链接,部署最省心;如果看到NEEDED libc.so.6之类,说明是动态链接,后面部署必须保证目标板有对应动态库。

第三步,strip一下去掉符号表,缩小体积:

arm-himix100-linux-strip memtester ls -lh memtester

strip前后的体积差挺明显,静态版本我从接近1MB缩到几百KB,对嵌入式部署友好很多。

3.3 把memtester部署到目标板

验证完二进制,接下来就是把文件弄到板子上。部署方式看你的现场环境,三种最常用:

第一种,开发阶段用NFS挂载。在开发机上配置好NFS目录,板子上执行:

mount -t nfs -o nolock 192.168.1.10:/export /mnt cp /mnt/memtester /tmp/ chmod +x /tmp/memtester

第二种,如果只是临时传一个文件,TFTP最快。把memtester放到tftp服务器的下载目录,板子上执行:

tftp -g -r memtester 192.168.1.10 chmod +x memtester ./memtester 200M 1

第三种,如果是量产阶段要常驻板子,直接把这个静态编译的memtester放进rootfs镜像里,烧录之后开机就能用。因为静态编译不依赖动态库,放在哪个目录都行,建议放到/usr/bin下,方便脚本调用。

不管用哪种方式,部署完第一件事先跑一个小尺寸测试,比如./memtester 16M 1,确认它能正常运行,再开始跑大内存的完整测试。这一步能帮你区分“工具部署有问题”和“DDR本身有问题”,免得后面被误导。

4. 上板跑DDR测试:参数选择与执行策略

4.1 测试内存大小的规划

在板子上跑测试之前,先判断该测多大内存。这一步特别多人翻车,有人上来就敲memtester 512M 1,结果板子总内存才512M,系统本身还要占用一两百MB,malloc直接失败,还以为是工具问题。

正确的做法是先看可用内存:

free -m

假设输出显示总内存512M,当前可用内存大约400M,那我一般会把测试大小设在250M到300M之间,留下足够的余量给内核、文件缓存和正在运行的Shell。千万不要把可用内存全部吃掉,不然内核为了腾内存会发生OOM,把测试进程干掉,结果一样测不到真实情况。

还有一个更稳妥的做法是分块测。内存大、测试时间长,一次测太多反而不利于定位。512M的板子可以分成两个256M区间,或四个128M区间,分批测。这样即使某一块出错,也能缩小故障范围,不至于看到一个错误地址还要猜是不是测试块太大导致的管理开销干扰。

4.2 常用命令与pattern的解读

memtester的使用格式不复杂,最常用的就是:

./memtester 256M 5

参数含义是:分配256MB内存,完整跑5轮测试。每一轮内部会依次执行很多种pattern的读写比较,包括经典的stuck address测试、随机数据读写验证、XOR比较、加减乘除运算验证、OR/AND逻辑运算以及顺序递增模式。这么丰富的组合就是为了覆盖DDR故障的多种成因,比如地址线粘连、数据线短路、cell保持能力不足、读写时序竞争等等。

运行过程中它会实时打印进度和当前正在跑哪个测试项。如果全部通过,最后退出码是0;如果某一项比对失败,会输出类似这样的信息:

FAILURE: possible bad ram - at offset 0x0004a2c0: expected 0x5a5a5a5a, got 0x5a5a5a58

看到FAILURE先别慌着下结论,这个输出信息量很大。expected和got的差异能帮你判断故障类型:如果是某一位被强行拉高或拉低,很可能是数据线问题或cell翻转;如果错乱非常随机、多次测试地址不一样,则更倾向于时序余量或供电问题。地址偏移量可以帮你对应到物理地址范围,配合板子的内存映射表,能进一步定位是哪条DDR颗粒的哪个bank。

一轮测试跑多久?跟内存大小、CPU频率和算法复杂度都有关,没有统一答案。256M在正常主频下,一轮有可能几分钟到十几分钟,如果你发现一轮跑了一个小时还没结束,就得检查一下是不是编译参数里开了什么奇怪优化,或者CPU频率被调得太低。

4.3 产线老化场景如何自动化执行

手动敲命令测一轮能应付开发阶段,但量产老化场景不可能人工盯着屏幕。我的做法是写一个循环脚本,让它没完没了地跑,边跑边记录时间戳和结果,留作测试证据。

一个很典型的量产老化测试脚本长这样:

#!/bin/sh i=0 while true; do i=$((i + 1)) ./memtester 256M 3 > /tmp/memtest_round.log 2>&1 if [ $? -eq 0 ]; then echo "$(date '+%Y-%m-%d %H:%M:%S') ROUND $i PASS" >> /tmp/ddr_test_history.log else echo "$(date '+%Y-%m-%d %H:%M:%S') ROUND $i FAIL" >> /tmp/ddr_test_history.log cat /tmp/memtest_round.log >> /tmp/ddr_test_history.log fi done

细节上有几点值得注意。第一,每轮log单独覆盖,不要追加到同一个文件,否则/tmp迟早被撑爆。第二,每次FAIL信息里带上完整时间戳和轮次,后期回看时能清楚知道问题出现的频率和间隔,这比单纯看到“FAIL”有用得多。第三,板子如果中途死机,脚本本身也会停,所以真正产线使用还要配硬件看门狗,或者让外部老化架在检测到死机后自动断电重启,启动脚本再接着跑。

4.4 测试结果的判定:pass并不等于万事大吉

这是我最想强调的一点:memtester跑全PASS,不代表DDR在所有情况下都可靠。memtester本质上是在用户态做功能验证,它测的是当前频率、当前电压、当前温度组合下的内存稳定性,条件一变,结果就可能不一样。

实际项目中我遇到过多次:常温下memtester跑了一整夜全PASS,板子一进高温箱,跑到两个多小时就出现FAIL;或者降压测试时,本来PASS的配置开始报错。所以DDR测试不能只看一轮结果,一定要结合使用场景去设计测试条件。做开发验证的时候,把频率拉高一档、把电压调低一点、放进老化箱里升温,这些“踩边界”的测试往往比常温默认参数更有价值。

另外,memtester本身占用的内存带宽不高,它大部分时间在做串行读写比较,而产品实际运行时DMA、视频编解码、网络收发会同时跑,内存控制器的并发压力要大得多。所以我还习惯在跑memtester的同时,并行起一路实际的业务负载,比如视频编码或持续网络吞吐,让DDR在满载情况下被“边查边用”。这比单纯只跑memtester更能接近真实工作条件。

5. 常见问题与排查技巧实录

5.1 编译问题:交叉编译器没配对,链接报错

先说编译阶段最常见的坑。很多人直接执行make,结果报一堆fatal error: stdint.h: No such file or directory或者cc1: unrecognized command-line option,一看就是没有把编译器切到交叉工具链,宿主机gcc还在那里干活。

这种问题排查很简单。先确认你的交叉编译器能不能独立编译一个hello world:

arm-himix100-linux-gcc -o hello hello.c

如果连hello都编不过,说明工具链本身有问题或环境变量没配好,就不要纠结memtester了。如果hello能编,再回头看make命令里的CC路径是不是写对了。我习惯把工具链的bin目录直接加到PATH里,然后统一用make CC=arm-himix100-linux-gcc,这样最不容易错。

另外有一种情况是工具链是32位ARM工具链,但编译时因为CFLAGS里带了-m32或者别的主机选项,导致链接报错,生成的文件在目标板上一跑就Illegal instruction。最简单的对照方式就是前面说的file命令,看好架构再上板。

5.2 运行问题:内存分配失败、一跑就死机

目标板上运行memtester,最常见的报错是:

failed to malloc 262144000 bytes

原因一般有三个。第一,测试大小超过了系统可用内存,这个用free -m一看便知。第二,进程的地址空间限制(ulimit)被设得太小,嵌入式环境有的脚本会默认调低限制,可以先用ulimit -a确认,必要时ulimit -v unlimited放行。第三,32位ARM的用户态虚拟地址空间本身有限,你想在1GB内存的板子上一次性测800MB,很可能malloc失败,这时候就要分块测,不要想着一次搞定。

还有一个更吓人的现象:工具一跑,系统直接死机或者内核panic。遇到这种情况,先别急着怪工具,第一步把测试内存减小到32M或64M,再试一试。如果小内存能跑,大内存死机,基本是DDR在高位地址区域有问题,或者内存控制器对大量连续访问支撑不住。如果小内存也死机,则要考虑工具二进制架构不匹配、目标板DDR初始化参数本身就处于临界状态。这时候应该退回U-Boot阶段,用mtest先做底层摸底,把“系统启动之前DDR是否稳定”这个问题单独拎出来验证。

另外有一种玄学情况:在板子上跑什么都不行,但二进制检查又是对的。我后来发现是工具链的浮点ABI和内核配置不一致,比如工具链编的是硬浮点,内核却没开浮点模拟,或者反过来。ARM平台上做移植,这个点翻车概率挺高,遇到Illegal instruction优先怀疑它。

5.3 结果问题:pass了但产品还在偶发故障

这是最让人头大的情况:memtester跑了几个晚上全PASS,但产品回归测试仍然偶发死机、花屏。如果碰到这种局面,不要质疑memtester没用,而是要考虑测试策略有没有覆盖到真正的使用场景。

第一,检查测试内存区域覆盖。memtester分配的是虚拟内存,它每次跑的时候物理页面映射位置不固定,如果只跑一次,可能并没有覆盖到出问题的那些物理bank。多跑几轮、同时稍微调整每次测试的内存大小,能提高物理地址覆盖的随机性。

第二,如果你怀疑是某个特定物理区域的问题,比如DMA缓冲或者Graphics内存,那就得用memtester的-p参数指定物理地址去测。但这个操作比默认的malloc方式麻烦,需要在系统里预留内存并确认映射方式,不是所有平台都能直接这么干。通常我会优先用U-Boot阶段的mtest去覆盖指定物理地址区间,效果更直接。

第三,检查业务负载和频率条件。前面说过,memtester对带宽压力不够,如果产品死机总是发生在视频编码或者网络高负载时,就在memtester跑的同时叠加真实业务,让内存控制器在重负载下来一次“考试”。很多隐蔽问题只有在带宽跑满时才会冒出来,这正是纯工具测试的盲区。

第四,别忽略温度。DDR对温度非常敏感,常温PASS只能说明常温没问题。如果产品应用环境温度变化大,一定要放到温箱里做高低温循环测试,测试结果的判断标准也要跟着改,不能拿常温标准硬套。

5.4 提高覆盖率与定位效率的实用技巧

最后一个板块,分享几个我实测下来比较管用的技巧。

第一,分块测试优于单块大测试。512M内存的板子,测2个256M比测1个512M更容易定位。万一某一轮FAIL,直接就能把怀疑范围缩到那256M区间内,再结合地址偏移做进一步的精确测试。

第二,控制变量。测试时关闭尽可能多的无关进程,尤其是GUI、日志打印、网络服务这些东西,它们会干扰内存分配,导致测试结果不稳定。产线老化模式里,我会在启动脚本里只拉起memtester和看门狗服务,其他什么都不要。

第三,测试记录要包含环境信息。每轮log不要只记录PASS或FAIL,最好同时记录时间、板卡编号、测试内存大小、DDR工作频率、机箱温度。数据积累多了以后,你再看到“温度升高后FAIL概率明显上升”这样的趋势,能比单次测试结果提供更多线索。

第四,bootloader阶段要和用户态测试配合。memtester跑在Linux用户态,测的是内核管理下的物理内存,而DDR控制器参数是在bootloader里设的。如果memtester频繁失败,回到U-Boot用mtest进行低层次读写测试,可以帮你判断问题到底出在DDR控制器配置上,还是出在后续总线/DMA环节。两个阶段互相印证,排查效率高很多。

第五,常用问题速查,我做了一个小表,方便现场对照:

现象可能原因排查方向
编译报缺失头文件用了宿主gcc编译检查CC变量,确认交叉工具链环境
报Exec format error二进制架构不匹配file命令确认ELF架构和位数
跑起来Illegal instruction浮点ABI与内核不匹配换匹配工具链或调整CFLAGS
无法malloc大内存测试尺寸超过可用内存用free -m确认,分块测试
跑一半系统死机DDR高位地址区故障或时序临界减小内存试跑,回到mtest做底层验证
PASS但产品仍偶发故障区域覆盖不足或业务负载不足多轮测试、叠加业务压力、高低温测试

最后聊聊我个人的体会

工具移植这件事,编译过程其实只占一半,另一半是理解你的平台和你的测试目标。我一开始也以为“移植memtester”就是把代码编出来扔到板子上,真正踩过几次坑之后才明白,同样的二进制在不同板卡上结果完全不同,往往是电压、温度、内存控制器参数这些环境变量在起作用,而不是工具本身有问题。

测试结果也要养成留痕的习惯。每次跑完,把输出保存成文件,标注好板卡版本、日期、测试内存大小和环境温度。以后再遇到偶发问题,翻历史数据能帮你省下大量时间。如果你只是跑完看一眼PASS就关掉,那等于什么都没测。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进