先说这次审计的起因。上个月在调一个 AArch64 平台上的时延敏感模块,perf 看了一眼热力图,发现 memcpy 和 strlen 这类基础函数的开销排在前面。当时第一反应是直接抄 glibc 的汇编实现,翻着翻着就翻到了 ARM 官方维护的 optimized-routines 仓库。这个库在嵌入式优化圈子里名声不小,但真正愿意逐行去读源码的人不多,大多只是编译产物直接链接用。这次我做了一次完整的源码静态审计和工程架构分析,把审计思路、工具链、以及几个让我印象深刻的实现细节整理成文。内容涉及字符串函数、数学库向量化、ABI 合规和微架构假设,适合正在做 ARM 平台性能调优、需要裁剪 libc 的开发者,也适合想学高质量 AArch64 汇编的读者。
1. 为什么要盯上 optimized-routines:从一次性能排查说起
1.1 这个库不是泛泛的“ARM 优化集合”
先说清楚 optimized-routines 到底是个什么东西。它由 ARM 官方维护,MIT 许可,代码量不大,但定位非常精准:提供面向 ARMv8-A 微架构的高性能基础函数实现,覆盖 string 目录下的内存与字符串函数(memcpy、memmove、memset、strlen、strcmp 等)、math 目录下的浮点数学函数(exp、log、pow、sin、cos 等,含标量与向量版本)、networking 目录下的网络校验和函数。它不试图成为一个完整的 libc,而是把“最容易被性能瓶颈卡住的基础函数”做深做透。
这点和很多人想象的不一样。它不是 glibc 的替代品,也不是给用户态应用直接调用的最终库,而是一份“经过微架构调优的参考实现”。glibc 的某些 AArch64 汇编实现、newlib 的某些优化段,甚至部分 RTOS 的 libsys 代码,都能看到这个库的影子。换句话说,它是 ARM 生态里基础软件性能优化的上游源头之一,读它比读一个普通开源库能获得更多“为什么这样写”的答案。
1.2 我的审计基线与环境约定
静态审计最忌讳没有基线。同一个文件在不同提交下可能面目全非,所以我这次拉取的是 GitHub 上 ARM-software/optimized-routines 的 master 分支,工具链用标准的 aarch64-linux-gnu-gcc 12.3,反汇编验证用 llvm-objdump,微架构分析用 llvm-mca,目标核选 public 的 Neoverse N1 作为代表。选择 Neoverse N1 不是因为它是最新的核,而是它有一份完整公开的微架构手册,指令延迟和端口占用数据透明,做性能建模时不会被黑盒参数卡住。
审计目标我列了四层:功能正确性、ABI 合规性、性能假设是否成立、可移植性边界在哪里。静态审计不是拿着源码从头到尾读一遍,而是带着这些问题去验证。比如一个汇编函数入口有没有维护正确的栈帧和对齐、符号类型是否声明正确、GNU property note 里有没有标记 BTI 和 PAC 支持,这些都属于 ABI 层面的检查项。性能假设则要结合微架构验证:某条循环采用 32 字节步进,是因为 L1 缓存行是 64 字节,两条 stp 正好覆盖一个缓存行,拆开写反而可能引发部分写失效。
1.3 静态审计到底审计什么
很多人以为源码审计等于“读代码找 bug”,但对我来说更重要的产出是一张决策表:这个函数为什么选这条指令序列,某个分支为什么放在循环外面,为什么用这种查表方式而不是另一种。把这些决策还原出来,才是审计的价值所在。
举个小例子。看到strlen的汇编实现里用了fmin这类向量指令去加速逐字扫描,要验证的不只是它能不能算对,还要确认它遵守了 ARMv8 的浮点语义。因为如果目标平台开了严格浮点异常模式,某些向量实现会在非 ASCII 文本上产生副作用。源码注释里通常会写“不设置 errno、不触发浮点异常”,但注释只是声明,调用约定是否真的不会破坏调用方的浮点状态,必须从指令序列层面确认。这就是静态审计和普通阅读最大的区别:你要把每一句“声称”变成可验证的“事实”。
2. 仓库骨架与构建体系:先看懂工程组织再读源码
2.1 目录分工:string、math、networking 各管一摊
optimized-routines 的仓库结构非常干净。顶层没有一堆 configure 脚本,只有 Makefile、README 和几个子目录。string 目录按架构分子目录,aarch64 放 AArch64 指令集版本,aarch32 放 AArch32 版本,每个函数一个 .S 文件。math 目录的结构稍微复杂一点,因为同一个函数可能有标量版本、Neon 向量版本、SVE 向量版本,以及供测试引用的头文件,比如 exp 会拆成 exp.h、v_exp.h、s_exp.c 之类。networking 目录相对独立,csum 相关实现和内核或用户态网络协议栈的解耦逻辑有关。
读到这种结构,第一件事就是确认自己关心的函数在哪个域里。如果目标是替换标准库的 memcpy,只看 string/aarch64/memcpy.S 就够;如果目标是复用一个向量 exp 算法,则要同时看 math 下的头文件、实现文件和测试用例,三者构成一个完整的交付单元。仓库作者没有把这些函数堆在一个大文件里,而是每个文件只做一个函数或一组同族函数,这本身就是嵌入式项目值得学习的组织方式——单个文件越小,静态审计边界越清晰。
2.2 Makefile 里的工程取舍
这个仓库没有用 autotools,也没有 CMake,而是简单的 Makefile。这一点非常符合它在生态中的定位:它不面向最终用户,而是面向集成者。集成者把源码或编译产物直接搬进自己的工程,Makefile 只是用于自测和生成库文件的辅助手段。
构建时一个关键参数是目标架构版本。以 math 目录为例,编译指令大致是:
make -C math CROSS_COMPILE=aarch64-linux-gnu- \ ARCH=aarch64 \ CFLAGS="-O3 -march=armv8-a+simd -fno-math-errno"-march=armv8-a+simd这里的 SIMD 其实是默认开启的,但显式写出来是为了让代码生成器明确自己有 Neon 可用。另一个值得注意的参数是-fno-math-errno:它告诉编译器数学函数不需要设置 errno,这正好和 optimized-routines 里数学函数的设计哲学一致——它们大多数故意不维护 errno 和浮点异常状态,从而换取更短的指令路径。
如果你在自己的工程里集成,建议不要直接改 Makefile,而是用自己构建系统的编译参数覆盖。因为仓库里的 CFLAGS 是给它的测试程序用的,你自己集成时对优化等级、安全和 ABI 的要求可能完全不同。把编译策略放在自家构建系统里,保留仓库的构建文件原样,后续跟进上游更新时冲突会小很多。
2.3 测试程序是怎么搭起来的
静态审计不能只靠读,必须跑起来验证。仓库的 test 目录提供了一套轻量测试机制,math 目录下有精度测试(ULP tests),string 目录下有功能正确性测试。直接编译运行测试的方式是:
make -C test -C math # 实际是进入 math/test 等子目录跑 ULP 测试时会输出每个函数的实测误差上限。比如 exp 的某种实现实测最大误差 1.2 ULP,说明它的多项式逼近系数选得好;如果某个函数测出 3 ULP,那就得返回去查查是不是查表步长或者区间划分出了问题。对于静态审计来说,这个测试报告是验证“源码里写得是否如它声称的那样精确”的最直接证据。
3. 字符串函数汇编级拆解:memcpy 的路径分级与对齐策略
3.1 函数入口处几个容易被忽略的细节
打开 string/aarch64/memcpy.S,第一眼注意力容易被大块拷贝循环吸引,但真正的门道在函数开头几行。一个合格的 AArch64 汇编函数,入口处必须处理三件事:栈帧和寄存器使用符合 PCS(Procedure Call Standard)、符号类型声明正确、可选的 BTI 分支保护指令。
简单说一下 PCS 的重要性。AArch64 调用约定规定 x0-x7 传参,x19-x29 是 callee-saved,x16/x17 是 intra-procedure-call scratch。memcpy 有三个参数:dst 在 x0,src 在 x1,n 在 x2。返回值必须是 x0,也就是修改后的 dst。这意味着任何在函数内部用到 x19 以上的寄存器都必须压栈保存,否则会污染调用方状态。优化库的实现通常会避免使用 callee-saved 寄存器,因为压栈和弹栈会带来固定开销,而 memcpy 这类函数本身的执行时间可能只有几十个周期。
另一个细节是符号声明。标准写法类似:
.globl memcpy .type memcpy, %function .hidden memcpy.hidden这个修饰符很有讲究。它告诉链接器这个符号不会在动态符号表里导出,这样编译器在同一个编译单元内可以直接做局部调用优化,也避免了某些场景下 PLT 跳转的开销。如果你在自己的汇编库里定义同名函数,可以考虑同样的处理,前提是你确定外部模块不需要直接调用它。
BTI 是 ARMv8.5 引入的分支目标识别指令,用于缓解面向跳转的 ROP 攻击。判断一份新代码是否适合现代安全要求,一是看汇编里有没有bti指令,二是看 ELF 头里有没有对应的 GNU property note。只有代码和编译选项都匹配,BTI 才能真正生效。这个细节在静态审计里不能漏,因为很多老库代码跑在支持 BTI 的平台上时存在安全问题,而这个问题运行时很难浮现。
3.2 按尺寸分级的拷贝路径
memcpy 的核心策略可以归纳为一句话:不同尺寸用不同策略,核心是减少分支次数和对齐检测次数。我审的这份代码里,大致存在三条路径。
小于 16 字节的拷贝走尾部分支,直接用 ldr/str 逐字节或按 8 字节对齐的方式搬,不进入主循环。16 到某个阈值之间的中等拷贝,采用双字加载存储做小循环,通常一次处理 32 字节。大块拷贝则进入主循环,优先用ldp/stp两个寄存器成对加载存储。
这套分级的意义在于:如果所有尺寸都走同一个循环,小数据会付出多余的循环开销和大块路径的预取指令代价;如果所有尺寸都走简单路径,大数据又无法利用现代内核的加载存储队列。分级是库实现对微架构带宽模型的直接映射。
3.3 对齐分支背后的缓存与端口模型
真正值得逐行分析的是对齐分支。AArch64 允许非对齐访问,但性能差异非常明显。比如目标地址是 16 字节对齐而源地址不是,理论上可以用两个加载指令加一个 orr 来拼接,但这样会引入额外的 ALU 开销和寄存器依赖,增加流水线停顿风险。
optimized-routines 的做法是先处理一个小的头块,让目标地址尽快变成 16 字节或 32 字节对齐,然后再进入无脑的宽加载循环。这个头块可能用单字节或双字节处理,具体取决于初始地址模 16 的余数。代价是最坏情况下多几个分支,但换来了主循环里稳定的指令流。
典型的宽循环代码形态大致是这个样子(已经去掉我这次审计的具体标签名,避免误导):
L(loop): ldp q0, q1, [src], #32 stp q0, q1, [dst], #32 ldp q2, q3, [src], #32 stp q2, q3, [dst], #32 subs n, n, #64 b.hi L(loop)这里一次迭代处理 64 字节,两个ldp和两个stp交错摆放。为什么要交错?关键在于现代内核的加载存储单元是并行的,load 和 store 可以同时发射,但同一个端口上的指令存在带宽上限。交错摆放可以让加载指令先填充 load queue,存储指令随后依赖已加载的数据写回,保证流水线不空转。如果顺序写成四个 load 再四个 store,store 阶段会因为数据还在 load queue 里等待而产生长延迟依赖,整体吞吐会明显下降。
缓存行角度也值得解释一下。典型 ARM 核的 L1 缓存行是 64 字节,一次循环迭代正好一个缓存行。拷贝这样的整数倍数据时,硬件预取器能很好地预测访问模式,不会因为越界访问触发额外缓存 miss。这也是为什么主循环步进通常选 64 或 32 而不是某个奇怪的数字。
3.4 用反汇编验证源码
静态审计过程中,“源码说什么”和“二进制实际做什么”是两回事。汇编源码经过汇编器之后可能插入重定位、调整立即数编码、甚至改变分支布局。我的验证流程是在仓库源码根目录单独汇编这个文件,然后立即反汇编:
aarch64-linux-gnu-gcc -c string/aarch64/memcpy.S -o /tmp/memcpy.o llvm-objdump -d /tmp/memcpy.o反汇编后逐条核对关键分支的偏移是否符合预期,重点看b.{eq,ne,hi}的跳转目标、循环入口是否落在对齐地址上、以及.p2align是否真的填入足够 pad。有些性能问题的根源就是.p2align只写了要对齐的目标没写最多填充字节数,导致汇编器在某些情况下插入超出预期的 padding,破坏了循环入口对齐。这种问题光读源码不一定看得出来,反汇编后一目了然。
4. 数学库的向量化密码:多项式逼近、ULP 与异常语义
4.1 为什么 math 用 C 而 string 用汇编
string 目录几乎清一色汇编,math 目录却以 C 为主,向量版本用 C 加内建函数。这个差异背后的工程考量很有意思。字符串函数的性能极依赖指令级微观调度和对齐控制,C 编译器生成的代码很难精确控制这种级别的东西;而数学函数的算法复杂度远高于指令调度复杂度,多项式系数、查表逻辑、区间约化才是难点,这些用 C 维护更容易,编译器生成的指令序列已经足够好,再手工抠汇编得不偿失。
另一个原因是数学函数需要同时维护标量版和向量版。向量版本的接口往往返回多 lane 的向量类型,用 C 写便于使用float64x2_t这类可读性强的内建类型。如果用汇编写,每个 lane 的装载和计算都要手工展开,代码量和维护成本会成倍增长,而且换一个向量宽度又要重写。所以优化库的布局本质上是在“人类维护成本”和“指令级控制力”之间做权衡。
4.2 多项式逼近与表驱动的实际配合
以我顺带读的 exp 系列为例,这类实现的核心套路是先把输入 x 变换到一个很小的区间,然后用少量多项式项逼近,最后用查表结果恢复指数值。具体说,先把 x 除以 ln2,得到整数 k 和小数 r,r 的范围被限制在 [-ln2/2, ln2/2] 区间。在这个窄区间里,e 的 r 次方可以用一个低阶多项式逼近。但那只是 e 的 r 次方,完整值还要乘以 2 的 k 次方,于是实现里会维护一组 2 的 k 次方的拆分表,表项拆成高半部分和低半部分,相乘时分别处理,以降低舍入误差。
这里面的精度控制非常讲究。如果直接把 2 的 k 次方乘上去,会引入较大的舍入误差。优化库的做法通常是将一次乘法拆成两次,先用高半部分得到大头,再乘小半部分补上余量。这种“double-double”或类似的技巧在标量实现里比较常见。
向量版本在区间约化和查表上要考虑怎么让 NEON 的 lane 保持同步。如果向量里的 4 个 lane 各自落在不同的区间约化分支,会产生分支发散,性能大打折扣。因此向量 exp 通常采用无分支的查表和 select 方式,把分支转换为算术运算或查表操作,保证所有 lane 都执行同样的指令流。这一点做静态审计时尤其要关注:看到vbsl或者整型移位截断时,都是在做无分支的条件选择。
4.3 ULP 精度与异常语义:注释里的承诺如何落地
数学库的 README 和头文件里会写明精度目标,比如“each function is accurate to within 1 ulp”或类似表述。但“精确到 1 ULP”不是靠嘴说,而是靠多项式阶数、表大小、计算顺序共同保证的。审计时要验证的是:作者声称 1 ULP,那测试程序里用的误差阈值是多少?如果测试阈值也是 1 ULP,那说明这是一个强约束;如果阈值是 2 ULP,那是留有安全边际的弱约束。
另一个在嵌入式场景容易踩的坑是异常语义。glibc 的 libm 会遵守 errno、fenv 异常标志等一整套浮点语义,但 optimized-routines 的数学函数通常不设置 errno,也不关心浮点异常标志。这是有意设计。对于不需要 SVID 语义的应用场景,省掉这些检查能显著降低指令数;但如果你的应用依赖严格的异常处理,直接替换 libm 就可能出问题。集成时要在性能和无异常语义之间做个明确选择,而不是默认它和 glibc 行为一致。
4.4 本地跑精度与性能实验
审计数学库时,建议在本地把它的 ULP 测试跑起来。如果只是静态读 C 代码,很难真正体会到整型 bit 操作和浮点运算交错时的微妙之处。测试框架会把输入范围划分成多个区间,对每个区间随机采样或边界采样,然后对比参考实现计算 ULP 误差。跑一遍之后会得到一张误差表,这张表就是你对这份实现精度最底层的信任依据。
性能层面可以用简单的计时程序做基线测试。注意比较对象不要选 glibc 的超高档优化,而是选你项目当前使用的 libm 版本。对比时记得开-O3 -fno-math-errno,否则编译器可能把函数调用优化成内建指令,测出来的数字没有意义。
5. 静态审计的工具链操作手册:从 readelf 到 llvm-mca
5.1 一套可复现的审计操作序列
如果你也想对类似库做一次审计,可以直接按下面的顺序来。这套流程我已经在 optimized-routines 上完整跑过,适用于大多数基础库级的汇编和 C 源码。
第一步,拉取源码并锁定基线。
git clone https://github.com/ARM-software/optimized-routines.git cd optimized-routines git log --oneline | head -5第二步,用目标架构工具链编译目标文件,但不链接成可执行文件。这一步只是验证汇编和 C 源码的语法和指令有效性。
aarch64-linux-gnu-gcc -c -O3 -march=armv8-a string/aarch64/memcpy.S -o /tmp/memcpy.o第三步,用 readelf 检查 ELF 头与安全属性。
readelf -h /tmp/memcpy.o readelf -n /tmp/memcpy.o-n输出的 GNU property note 里能看到代码是否声明了 BTI 或 PAC 支持。如果目标平台要求 BTI,而这里没有对应 note,就需要谨慎。还要用readelf -s看符号表,确认.type被标记为 FUNC。
第四步,反汇编并逐条分析关键循环。
llvm-objdump -d --no-show-raw-insn /tmp/memcpy.o第五步,把关键循环段截出来丢给 llvm-mca 做微架构建模。
# 先把循环指令保存到 loop.s,然后: llvm-mca -mcpu=neoverse-n1 -iterations=100 loop.s5.2 llvm-mca 能给出什么样的结论
llvm-mca 的输出里有三个数字最值得看:Instructions Per Cycle、Block RThroughput、以及每个硬件端口的压力分布。我举个例子。有一次我把 memcpy 主循环丢进去,发现stp指令在端口压力表上集中压在一个 store 端口上,导致每周期实际吞吐低于理论值。表面看这只是一个端口压满的问题,实际含义是:如果 target 核有更多 store 端口(比如 Neoverse N2 这类新核),这个循环可能不是最优形态。静态审计很难直接感知这种跨微架构的差异,但 llvm-mca 能给出量化提示。
用它分析时要注意:它假设指令流是理想调度,没有考虑缓存 miss、TLB miss 和分支预测失败。所以它的输出是“无惩罚条件下的上界”,而不是真实性能。我的用法是拿它做相对比较——同一段循环在两个不同核模型上的 IPC 差异能反映微架构敏感度。
5.3 审计中发现的几个典型边界情况
顺着审计流程,我把我观察到的一些边界情况列出来。这些不一定算 bug,但对集成者有实际价值。
内存别名问题。memcpy 和 memmove 的最大区别是前者不处理重叠,后者必须处理。在代码层面,如果两个地址区间重叠且来源靠前,直接正序拷贝会覆盖尚未读取的源数据。用户态一般会小心,但底层 RTOS 或驱动场景很容易踩坑。审计时至少要确认 memmove 有反向拷贝路径,否则标准库替换会有隐患。
非对齐访问的惩罚。某些 ARM 内核的非对齐加载会有额外周期惩罚,但用户态很难感知。优化库一般通过头块处理让主循环保持对齐,如果看到某个版本简化掉对齐分支,就要警惕它在非对齐输入上的性能回退。
.p2align的隐性影响。这一点在前面提到过,反汇编时如果发现某个循环入口没有按预期对齐,性能可能因此掉一截。新版工具链通常能处理好,但老工具链或自定义编译脚本里容易出问题。
还有一个值得注意的点是函数之间的 padding 区域。编译器或汇编器为了对齐会在函数之间填充 nop,这些 nop 一般无害,但如果有人手动改过源码导致 padding 数量骤增,会带来 icache 压力,函数多的时候影响就明显了。静态审计时可以把整个目标文件的尺寸和 padding 总量做个统计,超乎寻常的 padding 往往是有人手工补对齐补坏了。
6. 工程决策观察与可迁移的复用建议
6.1 一致性优先还是性能优先
读完整份源码,最让我有感触的一点是 optimized-routines 经常在“一致性”和“性能”之间选择前者。比如某些汇编函数不采用某条可以省几个周期的指令,因为那条指令在微架构 A 上表现好,在微架构 B 上却有反向惩罚。它宁可选择一个在大多数公开核模型上都稳定的方案,也不赌某个特定核的超能力。这种“平台中性调优”的思路,和很多草根优化项目“针对我手上这个核死磕到底”的路线截然不同。
这个取舍对集成者非常重要。如果你的产品只跑在一款固定芯片上,大可以根据自家核的微架构再改一轮;如果你要做一个覆盖多款 ARM 平台的通用底层库,那就应该直接信任这种保守选择,不要轻易替换成“看起来更快”的分支。
6.2 给你的项目复用这个库的取舍建议
想在自家项目里直接用 optimized-routines,有几个现实问题要处理。第一,许可与版权声明。MIT 许可很宽松,但如果你在二进制里包含它的汇编代码,建议保留原始版权信息,至少要在代码注释里留清楚出处。
第二,构建方式。不建议把它作为外部构建依赖引进来,因为它的 Makefile 是围绕自测设计的。更好的做法是把需要的源文件直接放进你的源码树,由你的构建系统统一编译。比如我只用它的 string 函数,就把 string/aarch64 下对应 .S 文件集成进来,在 Makefile 或 CMake 里指定编译选项即可。如果同时用 math 部分,还要注意把数学函数需要的头文件一并复制,头文件和源文件的配对关系是强耦合的。
第三,运行时替换策略。在用户态 Linux 上替换 glibc 的函数需要 IFUNC 机制或符号插入,不是简单地链一个静态库就能覆盖。嵌入式 RTOS 或裸机环境反而简单——没有动态链接,直接把符号对应进去就行。确定自己的使用场景,再决定集成深度。
最后,一定要保留测试。集成任何性能敏感的底层库,至少要在目标平台上跑一遍功能测试和精度测试。因为同一份源码在不同工具链、不同架构选项下,编译出的指令序列可能有细微差别,而汇编代码对工具链版本尤其敏感。我见过有人换了个新版本的 GCC 以后,老的嵌入式汇编直接编译失败,就是因为某个指令语法在新工具链里不再被接受。这个问题不属于源码本身,但属于工程落地的一部分,值得提前预防。
6.3 一次基于实际项目的复盘
最后补齐一点这次审计的原始动机。那个时延敏感模块最后并没有直接把它编进最终固件,因为模块本身需要严格的浮点舍入语义,和 optimized-routines 数学库“不维护 errno、精简异常”的取舍冲突。String 部分则顺利完成了替换,memcpy 在目标核上的实测吞吐比原 libc 版本提高了大概 17%,瓶颈从内存拷贝转移到了上游数据生产环节。
这个结果其实比“替换成功”更有参考价值:并不是这个库的所有部分都适合所有项目,关键是搞清楚自己的约束是什么。如果你的项目没有浮点异常语义的硬性要求,数学库带来的收益会非常可观;如果你有类似我这种特殊约束,那就把它当作一份高质量的算法参考,而不是必须照单全收的依赖。审计结束之后,我对 ARM 基础库的工程水准有了更具体的判断,也把整条静态审计流程沉淀成了自己的工具箱,后续在 RISC-V 平台的 libc 优化里还会继续用这套打法。这次经验的总结就写到这里,有问题欢迎交流。