简介:Arm Compiler 5.05 update 1(build 106)是面向嵌入式开发者的 ARM 编译器安装包,适用于维护基于 Keil MDK 的旧版工程,也适用于需要 r5p5-01rel1 编译链复现历史构建结果的场景,可缓解新版编译器兼容性差异带来的编译与链接问题。压缩包共 3 个文件,由 msi 安装数据、exe 引导程序与一份 html 版本说明构成,整体约 80.56MB,版本信息与组件构成一目了然。目前已有 890 人学习使用。资源保留了 2014 年 12 月发布的原始形态,便于读者在 Keil 环境中将其作为独立编译器版本接入,用于老项目维护、编译选项对比与工具链验证;配套说明文档也可辅助核对版本号与发布细节,适合需要固定编译环境、排查旧工程构建异常的工程师留存备查。
1. Arm Compiler 5.05 update 1 到底装在什么场景里
上周帮人看一个 Cortex-M3 的老工程,代码在旧机器上编得好好的,换到新装的开发环境里一编译就是一片 L6218E,undefined symbol 从启动文件一路报到大循环。查到最后不是代码问题,是工程里配的编译器指向了 armclang,而这份代码从启动文件到内联汇编全是 armcc 语法。这种事故在维护几年前的单片机项目时特别常见:工程能跑,工具链对不上。
Arm Compiler 5.05 update 1 (build 106) 就是 AC5 这条线上的完整工具链版本,armcc、armasm、armlink、fromelf、armar 一整套都在里面,专门伺候那些还停留在 armcc 语法、依赖 AC5 运行库、分散加载文件写死在 .sct 里的存量工程。手上是 Cortex-M0/M3/M4 的交付项目,或者要接手别人打包过来的 Keil 工程目录,这包东西基本是必需品。
2. AC5 的工具链构成:armcc 和 armclang 差在哪
先把选型这件事说清楚,不然装完编译器还是会用错。很多人以为 AC5 和 AC6 只是版本号差一位,装上就能互换,实际是两套前端完全不同的东西,混着用会出各种查不出原因的链接错误。
2.1 armcc 与 armclang 是两条独立的产品线
armcc 是 ARM 自研前端,血统可以追到 RVCT,AC5 是这条线的终点,最后一版停在 5.06 系列。armclang 基于 LLVM/Clang,从 AC6 开始成为默认编译器。两者输出的都是 ELF 目标文件,扩展名都是 .o,所以从文件管理器上看不出区别,但里面的符号修饰、运行库依赖、扩展关键字支持完全不是一回事。
最直接的差别在源码层。AC5 认__irq、__swi、__align(8)、__packed、__weak、__inline这一套 ARM 私有扩展关键字,AC6 里__align和__packed直接不给用,必须换成__attribute__((aligned(8)))和__attribute__((packed))。内联汇编更明显,AC5 的__asm { ... }嵌入式汇编块在 AC6 下要重写成 GNU 风格__asm volatile("...")。老工程里只要有一处启动文件或者临界区代码用了嵌入式汇编,换 AC6 就是编译不过,而不是警告。
第二个差别是运行库。AC5 链接的是 armlib / armcpplib,AC6 链接的是基于 LLVM 的那套库。两边都有 microlib 的精简版本,但实现不同。真正的坑在于第三方静态库:厂商给的 DSP 固件库、加密算法库、老版本 RTOS 的移植层,很多只提供 AC5 编译出来的 .lib。这种情况下你没得选,只能把 AC5 装回来。
第三个差别是 pragma。#pragma arm section、#pragma import(__use_no_semihosting)、#pragma anon_unions这些在 AC5 里是常规操作,AC6 要么不支持,要么行为变了。接手老工程时,我一般先全局搜一遍#pragma和__asm,搜出来的数量基本就决定了这个工程能不能迁 AC6。
2.2 5.05 update 1 里都有什么:五个可执行文件的职责
解压后 bin 目录下的可执行文件不多,但每一个都对应构建流程里的一个环节,分工很清楚。下面这张表是我自己贴在工位上的速查,参数列只写了最常用的那几个。
| 可执行文件 | 职责 | 典型参数 |
|---|---|---|
| armcc | C/C++ 编译,产出 .o | -c、-O2、--cpu、--apcs、--c99、-I、-D |
| armasm | 汇编 .s 文件,产出 .o | --cpu、--apcs、-g、--pd |
| armlink | 链接,产出 .axf/.elf | --scatter、--map、--info、--library_type |
| fromelf | 格式转换与信息提取 | --bin、--i32combined、--text -c、--sizes |
| armar | 静态库打包 | --create、--runtime |
除了 bin,安装目录里还有两个必须知道的子目录。一个是 include,放着编译器和运行库的头文件,stdio.h、string.h、stdint.h这些都在里面,用命令行构建时必须把这一层加进头文件搜索路径,否则连#include <stdint.h>都过不去。另一个是 lib,按 CPU 架构和大小端分成若干子目录,armlink 会根据--cpu自动挑,但如果你手工指定了--userlibpath,就得自己保证路径对得上。
版本信息可以直接问编译器,这是确认自己手上到底是哪个 build 的最快方式:
# 打印编译器版本,确认 build 号 armcc --vsn # 输出形如: # Product: ARM Compiler 5.05 update 1 # Component: ARM Compiler 5.05 update 1 (build 106) # Tool: armcc--vsn的输出里那条 Component 行就是版本全称,跟压缩包名字对得上才说明没装错。如果工程里同时在用 C++,还要额外跑一次armcc --vsn --cpp,因为 C++ 前端是独立组件,缺失的情况下编译 .cpp 文件会直接报找不到组件。
2.3 版本号怎么读,什么工程该停在 5.05
5.05 update 1 里的 update 不是补丁序号那么简单,它代表一个维护分支,build 106 是这个分支上的构建号。判断兼容性别只看 5.05 这三位,还要看工程用到的内核架构。
我一般的判断顺序是这样的。先看内核:Cortex-M0/M0+/M1/M3/M4 这条线,5.05 完全够用;Cortex-M7 需要带 FPU 的--cpu=Cortex-M7.fp.dp形式,5.05 支持但个别优化选项行为跟 5.06 有出入;如果工程是 Cortex-M23/M33 这类 ARMv8-M 内核,我建议直接放弃 5.05,这条线对 ARMv8-M 的支持不完整,实测会在 TrustZone 相关的段属性上出问题,老老实实上 5.06 或者 AC6。
再看运行库。工程如果用到了 C++ 异常、RTTI、std::string这类重货,AC5 的 C++ 库对 C++11 支持是部分的,参数要显式开--cpp;如果只是用了类封装和模板,问题不大。用 microlib 的工程要特别注意,microlib 不支持文件系统、不支持重入,用到malloc的工程里一旦有多任务同时申请内存就会踩雷,这点跟版本无关,但换编译器时最容易被忽略。
最后看分散加载。5.05 的 armlink 对 scatter 文件的语法检查比后来的版本松一些,+First、InRoot$$Sections、.ANY混着写也能过。如果以后升 5.06 或者换 AC6,这些松散写法可能会变成 warning 甚至 error,所以我习惯在新工程里就把 scatter 写得规矩一点,别给未来埋雷。
3. 把编译器接进工程:三条接入路径与参数清单
装好只是第一步,能不能跑起来取决于怎么把它接到构建系统里。图形界面和命令行两条路我都走过,各有各的坑,下面按实际落地顺序展开。
3.1 路径一:在 IDE 里指定 ARMCC 目录
绝大多数人拿到 AC5 是为了救活一个 Keil 工程。做法不复杂,但要改对地方。打开工程选项,找到 Target 页里的编译器版本选择,把 ARM Compiler 下拉框切到 "Use default compiler version 5" 或者手动指定安装目录。切完之后必须做一次全量重编,因为之前的中间文件是用 armclang 生成的,格式和运行库引用都不一样,增量编译会留下一堆 ABI 不匹配的 .o。
判断切换是否生效有个土办法:看编译输出里每条编译命令的前缀。armcc 的调用形如Armcc.exe --cpu=Cortex-M3 ...,armclang 则是Armclang.exe --target=arm-arm-none-eabi ...,一眼就能分辨。如果切完之后输出里还是 armclang,说明工程用的是 per-file 的编译器覆盖设置,得在文件属性里逐个改回来,或者直接清掉工程目录下的*.uvoptx重新配。
图形界面这条路的好处是省事,坏处是不够透明。一旦出错,你很难判断是 IDE 传的参数有问题,还是工程本身的源码有问题。所以我遇到短时间内搞不定的链接错误时,会切到第二条路去验证。
3.2 路径二:Makefile 命令行构建
把构建从 IDE 里剥出来,是排查工具链问题最有效的方式,也是 CI 环境必须走的路。下面这份 Makefile 是最小可用版本,我拿它验证工具链和参数,跑通了再往回套到工程里。
# 工具链路径按实际安装位置改,这里假设解压到 /opt 下 TOOLCHAIN := /opt/ARM_Compiler_5.05_106/bin CC := $(TOOLCHAIN)/armcc AS := $(TOOLCHAIN)/armasm LD := $(TOOLCHAIN)/armlink ELF := $(TOOLCHAIN)/fromelf TARGET := build/app CPU := --cpu=Cortex-M4.fp SRCS := $(wildcard src/*.c) ASMS := $(wildcard src/*.s) OBJS := $(patsubst src/%.c,build/obj/%.o,$(SRCS)) \ $(patsubst src/%.s,build/obj/%.o,$(ASMS)) # -c 只编译不链接;--apcs=interwork 保证 ARM/Thumb 状态可互调 CFLAGS := $(CPU) -c -O2 --apcs=interwork --c99 -g \ -Iinc -Idrivers \ -DUSE_FULL_ASSERT --diag_suppress=1 AFLAGS := $(CPU) --apcs=interwork -g # --map 和 --list 同时给,map 文件才完整;--info 决定打印哪些统计 LDFLAGS := $(CPU) --scatter=board/app.sct \ --map --list=$(TARGET).map \ --info=sizes,totals,unused all: $(TARGET).axf $(TARGET).axf: $(OBJS) $(LD) $(LDFLAGS) --output=$@ $(OBJS) build/obj/%.o: src/%.c @mkdir -p $(dir $@) $(CC) $(CFLAGS) -o $@ $< build/obj/%.o: src/%.s @mkdir -p $(dir $@) $(AS) $(AFLAGS) -o $@ $< hex: $(TARGET).axf $(ELF) --i32combined --output=build/app.hex $< bin: $(TARGET).axf $(ELF) --bin --output=build/app.bin $< dis: $(TARGET).axf $(ELF) --text -c --output=build/app.dis.txt $< clean: rm -rf build配方行前面的缩进必须是真实的 Tab 字符,编辑器把 Tab 转成空格是最经典的翻车原因,报错是missing separator,跟编译器无关。
几个参数值得单独说明。--cpu必须和芯片内核严格对应,Cortex-M4.fp里的.fp表示带单精度 FPU,漏了这两个字符,编译出来的浮点运算全是软件模拟,跑起来慢一个数量级还查不出原因。--apcs=interwork确保 ARM 状态和 Thumb 状态之间可以互相调用,虽然 AC5 默认已经是 interwork,但老工程从别处拷来的 Makefile 里经常残留--apcs=/nointerwork,链接时会报一堆 veneer 相关的问题。--c99影响的是变量声明位置、//注释、变长数组这些语法,工程里用了for (int i = 0; ...)就必须开,否则报expected an expression。--diag_suppress=1压掉的是文件末尾缺换行这类噪音警告,编号按自己工程实际的警告来填,不要照抄。
这份 Makefile 用的是全量重编,源文件不多的时候够用。工程大起来之后,头文件改动的依赖关系不跟踪,会出现改了结构体定义但只重编了部分 .o 的情况,链接时的表现非常诡异,后面避坑章节会专门讲。
3.3 编译参数怎么设:从优化等级到接口约定
参数不是越多越好,能跑通之后再逐个加。我一般的顺序是:先-O0加-g跑通功能,再上优化等级验证,最后才开那些会改变语义的激进选项。
优化等级从-O0到-O3之外,AC5 还提供-Ospace和-Otime两种倾向。-Ospace优先压缩代码体积,适合 Flash 吃紧的低成本方案;-Otime优先提升执行速度,代价是代码膨胀,中断服务函数多的工程慎用,容易把关键的几个函数挤出 cache 友好的布局。我见过一次现场复位,最后定位到-Otime把某个中断里调用的小函数展开成内联,栈深度超了,换成-O2立刻正常。
警告级别建议至少开到工程能接受的最严程度。-W打所有警告,配合--strict让编译器对可疑写法较真。刚上 AC5 的工程往往会冒出一两百条警告,不要一次性全开再逐个压,先把错误清零,再分模块把警告清零,最后才用--diag_suppress处理确实无法改的第三方代码。
单独提一个容易漏的:char的符号性。armcc 默认char是无符号的,这点跟很多开发者的直觉相反,从别的工具链迁过来的代码里如果有char c = -1;之类的写法,判断if (c < 0)永远不成立。要改的话加--signed_chars,但改之前必须确认所有依赖位运算的代码没有假设无符号,这个切换是全局性的,改完要全量回归。
3.4 链接与产物校验:分散加载和 fromelf
链接阶段的核心是 scatter 文件。一个能用的最小模板长这样,结构是 LR 开头加载域套 ER 开头执行域,+First保证复位向量排在最前面,InRoot$$Sections是 C 库的根区段必须显式包含:
LR_IROM1 0x08000000 0x00040000 { ER_IROM1 0x08000000 0x00040000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00010000 { .ANY (+RW +ZI) } }0x08000000和0x20000000是 Flash 和 RAM 的起始地址,长度字段必须按芯片实际容量填。这两个数字填大了不会报错,但下载到板子上会跑飞;填小了链接时直接报 L6406E 空间不足。我见过有人把 RAM 长度按手册的标称值填,忽略了前面几十 KB 被 bootloader 占掉的情况,链接一切正常,上电就进 HardFault。
链接完成后必须看一眼 map 文件。最省事的做法是看 armlink 在控制台打印的 totals 行,里面会给出 Code、RO Data、RW Data、ZI Data 四项的总和。真正的空间判断要看 ROM 占用 = Code + RO Data + RW Data,RAM 占用 = RW Data + ZI Data,很多人只盯着 Code 那一列,忽略了 RW Data 在 Flash 里也要占一份初值副本。
工程大了之后手工看 map 不现实,我一般挂一个几十行的脚本做汇总,按目标文件排序,谁占得多一目了然:
import re import sys from pathlib import Path # map 里 Image component sizes 段落的行首是若干列数字,后面跟目标文件名 # 不同 build 的列数不一样(有的带 IRAM 列),这里只抓数字组,按位置解释 ROW = re.compile(r"^\s*((?:\d+\s+){3,})(\S.*)$") def load_rows(map_path): rows, in_table = [], False for line in Path(map_path).read_text(errors="ignore").splitlines(): if "Image component sizes" in line: in_table = True continue if not in_table: continue if line.strip().startswith(("Grand", "Total")): in_table = False continue m = ROW.match(line) if not m: continue nums = [int(x) for x in m.group(1).split()] name = m.group(2).split()[0] # 约定:0=Code 1=RO Data 2=RW Data 3=RW Zero if len(nums) < 4: continue rows.append((nums[0], nums[1], nums[2], nums[3], name)) return rows def top(map_path, n=20): rows = sorted(load_rows(map_path), key=lambda r: sum(r[:4]), reverse=True) print(f"{'Code':>8}{'RO':>8}{'RW':>8}{'ZI':>8} Object") for code, ro, rw, zi, name in rows[:n]: print(f"{code:>8}{ro:>8}{rw:>8}{zi:>8} {name}") if __name__ == "__main__": top(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 20)脚本的核心是按行首连续数字组切分,而不是写死列偏移,这样换 build 号也不至于立刻失效。name取的是数字后面的第一个 token,如果工程里目标文件名带空格,这里会被截断,需要改成取到行尾再 strip。排序键用的是四列之和,实际排查 RAM 溢出时应该只按 ZI 排序,改一下key就行。
4. 避坑实录:AC5 上手最容易翻车的五个点
这一节都是实际踩出来的,每条按现象、原因、解决三段走,能对上号的基本就不用再翻文档了。
4.1 混链 AC5 和 AC6 的目标文件
现象:链接时报一堆 L6218E,undefined symbol 全是__aeabi_开头的运行库符号,或者报重复定义,明明每个源文件都编过了。
原因:工程里一部分 .o 是 armcc 编的,另一部分是 armclang 编的。两者虽然都是 ELF,但引用的是不同的 C 运行库实现,符号集合不重合。常见触发场景是从别的工程拷了几个 .o 过来,或者中途换过编译器版本但没 clean。
解决:全量 clean 之后重编,不要相信增量编译。判断是否有混链的最快方式是看 map 文件末尾的库列表,里面会列出实际参与链接的 .a 文件路径,出现两个不同目录下的 armlib 就说明混了。此后我在 Makefile 里固定了TOOLCHAIN变量,所有路径都从它派生,杜绝手写路径。
4.2 半主机报错与重定向
现象:链接报 L6915E,提示__use_no_semihosting was requested, but _sys_open was referenced,或者程序一调 printf 就卡死。
原因:代码里用了#pragma import(__use_no_semihosting)声明不使用半主机,但 C 库内部的 printf 仍然引用了需要半主机的底层接口,两边打架。
解决:补齐重定向函数,把_sys_exit、_ttywrch、fputc这几个桩函数实现掉,串口发送换成自己板级的写字节函数:
#pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; /* 半主机模式下程序退出会陷入调试器,这里改成原地死循环 */ void _sys_exit(int x) { (void)x; while (1) {} } void _ttywrch(int ch) { (void)ch; } /* printf 最终走到这里,换成自己的串口发送 */ int fputc(int ch, FILE *f) { (void)f; uart_write_byte((uint8_t)ch); return ch; }struct __FILE的定义必须和库里的一致,少写字段会在链接期报类型冲突。_sys_exit的形参名可以改,但签名不能改。如果工程完全不打印,更省事的办法是链接时加--library_type=microlib,精简库不带半主机逻辑,代价是失去重入和文件系统支持。
4.3 改了头文件不重编,现象像玄学
现象:结构体加了个成员,编译通过,下载到板子上跑飞,或者某个变量读出来恒为 0,加打印又正常了。
原因:Makefile 里没有跟踪头文件依赖,只改了 .h 的情况下,包含它的 .c 没有重新编译,链接进去的还是旧的目标文件,两边对同一个结构体的布局理解不一致。这种 bug 最难查,因为现象会随优化等级和链接顺序变化。
解决:小工程就在改头文件后强制make clean,这是最笨但最可靠的办法。工程大了之后上依赖生成,用脚本扫一遍#include关系生成 .d 文件,或者干脆在 Makefile 里把头文件列表作为所有 .o 的额外依赖。我现在所有工程的clean都绑在切换编译器、改配置头文件这两个动作上,宁可多编两分钟。
4.4 char 的符号性与结构体对齐
现象:从别的平台迁过来的代码,校验和计算结果对不上,或者协议解析出来的长度字段是个巨大值。
原因:两个语义差异同时存在。armcc 默认char无符号,别家工具链默认有符号,位移和比较的结果就变了;结构体对齐规则也不同,AC5 默认按成员最大宽度对齐,__packed才是紧凑排列,而别家可能默认就是紧凑的。
解决:涉及协议的代码一律用uint8_t/int8_t,不要用裸char。结构体映射寄存器或者通信帧时显式加__packed,但加了之后访问成员会生成逐字节读写,性能敏感的地方要评估。反过来,需要按字对齐访问的结构体加__align(4),AC5 认这个写法,AC6 不认,迁移时是必改项。
4.5 路径、编码与中文注释
现象:编译报cannot open source input file,路径明明存在;或者报一堆莫名其妙的语法错误,指向的行号是中文注释所在行。
原因:路径里带空格,命令行参数被截断;源文件编码是 UTF-8 带 BOM 或者 GB2312,编译器按本地代码页解释,中文注释里的字节被当成了非法字符。AC5 对编码的处理比较老派,对 BOM 尤其敏感。
解决:工程路径统一用纯 ASCII 且不含空格,我见过把工程放在桌面中文目录下排查两小时的最后发现是路径问题的。源文件统一存成 UTF-8 无 BOM,或者干脆把注释写成英文。团队协作时在编辑器里定一个.editorconfig约束编码,比事后追查省事得多。
5. 用反汇编给优化结果做体检
编译器优化的结果不能靠猜。-O2之后某个函数被内联了、某个循环被展开了、某段代码被彻底删掉了,这些在源码上看不出来,但在反汇编里一目了然。我一般在两个时间点必看:一是从-O0切到-O2之后,二是怀疑某个变量被优化没了的时候。
先出反汇编文本:
# -c 表示带源码注释,排查时对照源码最快 fromelf --text -c --output=build/app.dis.txt build/app.axf # 只看符号大小排序,快速找异常膨胀的函数 fromelf --sizes --output=build/app.sizes.txt build/app.axf--text -c生成的文本里,每个函数前面会标注它在源码里的位置,往下就是对应的汇编写法。我通常搜函数名定位,重点看三件事:函数是不是真的存在(没被内联掉)、关键变量是不是每次都从内存重新读(而不是被缓存进寄存器)、循环体是不是被展开成了大段重复指令。
举个我自己翻过的车。一个轮询标志位的循环,源码写的是while (flag == 0);,flag声明成了普通变量而不是volatile,-O0下一切正常,切到-O2之后反汇编里整个循环只剩一条无条件跳转,编译器认为flag在循环内不会被改变,直接把读操作提到了循环外面。板子上表现就是死等。这类问题看源码永远看不出来,看反汇编三秒就定位了。
还有一个技巧是拿--sizes的输出跟上一版对比。同一个函数在两次提交之间体积突然涨了几百字节,多半是某个内联阈值被触发,或者加了新的模板实例化。这种体积突变往往是性能回归的前兆,尤其在 Flash 紧张的小容量芯片上,涨着涨着就链接不下了。
从那以后我每次换编译器版本、改优化等级、动 scatter 文件,都强制走一遍 map 加反汇编的体检流程,哪怕只是从一个 build 升到下一个 build。多花五分钟,能省掉在板子前面蹲一下午的时间。希望帮到你。
本文还有配套的精品资源,点击获取