
从 .c 到 .elf:GHS 编译器下 RH850 C 文件完整编译流程深度拆解
预编译 → 编译 → 汇编 → 链接,四个阶段各做了什么?宏、全局变量、局部变量、const 常量、函数,又在哪一阶段、以怎样的方式被引用?本文以 GHS(Green Hills Software)编译器编译瑞萨 RH850 车规 MCU 工程为背景,把整条链路一次性讲透。
一、开篇引言
做汽车电子底层开发的朋友,几乎每天都在和编译打交道:在 Multi 里点一下 Build,几分钟后一份 .elf 就出来了,接着用 Flasher 烧进 RH850,看它跑起来。但"能编译通过"和"真正理解编译发生了什么"是两回事。
我见过不少同行遇到这类问题无从下手:
- 报
undefined reference to "func",明明是同一个工程,为什么链接找不到函数? multiple definition of "g_val",只是把全局变量放在头文件里,就炸了?- 宏改了值、重新编译,现象却"没生效"------其实它早就被预编译期展开,根本没进汇编阶段;
- 想在链接脚本里定位一个
const常量,却不知道它落在.rodata还是.sdata。
这些问题的答案,全都藏在编译的四个阶段里。本文的写作目的,就是帮你建立一张"源码 → 符号 → 地址"的完整地图:预编译、编译、汇编、链接各阶段分别做了什么、为什么存在、有什么意义,以及宏、全局变量、局部变量、const 常量、函数这五类最常见的 C 元素,在每个阶段分别以什么形态被引用、被处理。
读完你会收获:能看懂 GHS 生成的中间文件(.i / .s / .o),能自己定位和解决 90% 以上的"符号类"编译链接错误,并在做 RH850 启动、链接脚本、内存布局时建立更系统的认知。
全文结构如下:先讲四个阶段的原理与角色;再用一个可复现的 RH850 例程,逐步带你走完 GHS 命令行全流程;最后总结常见踩坑与工程最佳实践。
二、核心原理拆解:四阶段各司其职
C 源文件到可执行映像,本质上是把"人类可读的文本"逐步翻译成"机器可执行的二进制",并在过程中完成符号的组织与地址的分配。这个翻译不是一步到位的,而是拆成四个边界清晰、可独立验证的阶段:
text
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ main.c │──▶│ 预编译 │──▶│ 编译 │──▶│ 汇编 │──▶│ 链接 │──▶│ app.elf│
│ (源码) │ -E │ │ -S│ │ -c│ │ │ │ │(可执行)│
└────────┘ │ main.i │ │ main.s │ │ main.o │ │ │ └────────┘
└────────┘ └────────┘ └────────┘ └────────┘
图 1:GHS 编译链接四阶段总览(GHS 编译器 / RH850 环境)

上图标出了 GHS 各阶段对应的子工具(preprocessor / compiler / assembler / linker gelfink)与中间产物;配合上面的 ASCII 流程图,可一眼看清 .c 经 -E/-S/-c 逐步产出 .i → .s → .o,最后链接为 .elf。
2.1 阶段一:预编译(Preprocessing,-E)------纯文本层面的"代工"
预编译处理的对象是纯文本,它不识别 C 语法,只做文本级的替换与拼接。核心工作:
- 宏展开 :把
#define定义的宏,在源码中出现的位置全部替换成其定义体; - 头文件包含 :把
#include指向的头文件内容"原样粘贴"进来(可递归); - 条件编译 :依据
#if/#ifdef/#elif等,裁剪出当前配置下的有效代码段; - 删除注释 、处理
#line、#pragma等指令。
意义 :它把"带配置的、多文件的源码"统一展平成一份单文件、无宏、无注释、条件已定 的翻译单元(translation unit),为下一步真正的语法分析做好干净输入。在 GHS 中可用 -E 得到展开后的 main.i。
2.2 阶段二:编译(Compilation,-S)------语法 / 语义分析,生成汇编
编译阶段开始理解语言的语义,是四阶段中技术含量最高的一环:
- 词法、语法、语义分析:检查语法错误、类型不匹配、未声明变量等;
- 类型检查与隐式转换、作用域解析;
- 生成中间表示并优化 (常量折叠、死代码消除、寄存器分配等,GHS 提供
-On优化档); - 段归属决策 :编译器根据变量 / 函数的属性(
const、已初始化、未初始化、代码等),在生成汇编时用.section .text、.section .rodata、.section .data、.section .bss等伪指令 ,明确标注每段代码 / 数据应归入哪个段------段归属在这一步就已经定了; - 输出汇编代码
main.s(RH850 架构的指令助记符)。
意义 :它把"符号(变量 / 函数)"和"指令"绑定,并为每一个有定义 的全局符号在符号表 中登记;同时记录"哪里引用了尚未定义的符号",为后续重定位做准备。段归属(进哪个段)也在这一步确定,汇编器只是照章执行。
2.3 阶段三:汇编(Assembly,-c)------把汇编翻译成机器码
汇编器把 RH850 汇编代码(.s)翻译成目标文件 (.o,ELF 格式的可重定位文件):
- 每条指令转为对应机器码;
- 按
.section伪指令装箱 :汇编器不决定"进哪个段",只是执行 编译器已经写好的段归属指令,把机器码和数据按段组织到.o文件中; - 生成符号表 (
.symtab):记录已定义的全局 / 局部符号及其在当前模块内的段内偏移; - 生成重定位表 (
.rela.*):记录哪些位置需要链接期回填地址。
意义 :.o 是"可独立成块、但地址未定"的机器码模块。段在 .o 内部的组织和相对偏移已定,但最终内存地址仍未分配------这正是它区别于最终可执行文件的关键。
2.4 阶段四:链接(Link)------符号解析 + 地址分配
链接器(GHS 为 gelfink,遵循链接脚本)把多个 .o(以及启动代码、库)合并为最终可执行映像:
- 符号解析(symbol resolution) :把每个"引用到的未定义符号"与"某个
.o中定义的同名符号"对上号;对不上就报undefined reference; - 重定位(relocation):按最终布局,把每处引用回填成真实内存地址;
- 段合并与最终地址分配 :按链接脚本(GHS
.lcf/ GNU ld 风格.ld)把各.o的同名段合并,分配到 RH850 的具体内存区(Flash / RAM),并处理对齐、启动段、中断向量表等; - 生成可执行映像
app.elf(及可选的 S-record / HEX 烧录文件)。
意义 :它把"分散的可重定位模块"变成"能直接烧录运行的、地址完整确定"的最终产物。整个工程的符号关系,只有到链接才真正闭环。
段归属 vs 段地址------一句话记住 :
编译决定"进哪个抽屉",汇编"把东西放进抽屉",链接"给抽屉贴上房间号"。段归属在编译期就定了,汇编只是照章装箱,链接才分配最终物理地址。
2.5 五类符号在四个阶段中的"引用地图"
这是全篇核心。下面用一张对照表,把宏、全局变量、局部变量、const 常量、函数在四个阶段各自的形态说清楚:
| C 元素 | 预编译(-E) | 编译(-S) | 汇编(-c) | 链接 |
|---|---|---|---|---|
宏 #define |
全部就地展开,替换为定义体,产物中已无宏名 | 不再出现,已是具体代码 / 常量 | 无 | 无(完全消失) |
局部变量 int loc |
原样保留 | 进入作用域分析,可能被优化;存在栈或寄存器 | 反映为栈偏移 / 寄存器分配 | 不产生全局符号,不进链接符号表 |
全局变量 int g_val |
原样保留 | 登记为强 / 弱全局符号 ;决定落入 .data/.bss |
按 .data/.bss 装箱,写入 .symtab 并标记全局 |
若被跨文件引用→符号解析;分配最终地址 |
const 常量 const int C |
原样保留 | 识别为只读对象;决定落入 .rodata |
按 .rodata 装箱 |
定位到只读区(Flash),运行时不可写 |
函数 int func() |
原样保留 | 生成函数体指令 + 函数符号;决定落入 .text |
按 .text 装箱,符号进 .symtab |
跨文件调用时符号解析 + 重定位回填地址 |
几个值得强调的工程要点:
- 宏是"编译前"的产物,与运行时零关系 。所以"宏没生效"先查
-E结果,而不是看最终.elf。 - 局部变量不参与链接,它只活在编译与汇编的"局部符号"层面,通常被寄存器 / 栈承载,优化后甚至完全消失。
- 全局变量与函数是链接的主角 :跨文件使用,靠
extern声明 + 链接期符号解析;重复定义或漏定义,报错都发生在链接。 - const 常量属于"只读段",进 Flash 。在 RH850 上,试图写
.rodata会触发异常或写保护错误------这是嵌入式特有的坑。
三、实操落地指南:用 GHS 命令行走完全流程
下面用一个极简的 RH850 例程,演示如何手动分步 执行四个阶段,并查看每步产物。环境假设:GHS MULTI 已安装,GHS 的 RH850 C 编译器驱动在 PATH 中(工程上常以 ccrh850 / cxrh850 调用,选项风格与 GCC 高度一致),目标为 RH850/G4MH(28 位 / 32 位地址模式均可,不影响流程)。
说明:GHS 命令行选项与 GCC 风格类似但不完全相同。为便于通用理解,下文用
-E/-S/-c这类阶段选项描述流程;实际工程中请以ccrh850 --help或《GHS Compiler User's Guide》中对应选项为准。
3.1 准备源文件
先准备一个跨文件引用的最小工程,制造"全局变量、const、宏、局部变量、函数"全部登场的机会。
main.c:
c
/* main.c --- GHS 编译流程示例 */
#include "config.h"
#include <stdio.h>
int g_val = 5;
const int C_CONST = 10;
int add_one(int x)
{
int loc = x + 1;
return loc;
}
int main(void)
{
int result = add_one(g_val) + C_CONST;
printf("result = %d\n", result);
return 0;
}
config.h:
c
/* config.h */
#ifndef CONFIG_H
#define CONFIG_H
#define VERSION 3
#endif
注意:为了专注编译流程,这里把
add_one定义在main.c内;实际工程常拆到多个.c,以演示跨文件链接(见第 4 节踩坑)。
3.2 第 1 步:预编译(-E)
bash
ccrh850 -E main.c -o main.i
- 做了什么 :展开宏、粘贴
config.h与stdio.h、删除注释、按条件裁剪。 - 验证方法 :打开
main.i,你会看到:VERSION已被替换为字面量3;config.h/stdio.h的内容被"粘贴"进来;- 注释被移除。
- 关键点 :这一步之后,源码里再也看不到
#define和#include,它们是纯文本操作。
查看宏是否展开:
bash
grep "VERSION" main.i
3.3 第 2 步:编译(-S)
bash
ccrh850 -S main.i -o main.s
- 做了什么 :语法 / 语义分析、类型检查、优化(默认
-O0便于观察;可用高优化档对比)、生成 RH850 汇编,同时决定每个符号的段归属。 - 验证方法 :打开
main.s,你会看到:- RH850 指令助记符(如
mov,add,ld.w,st.w,jr等); - 出现
.section .text、.section .rodata、.section .data等段标记------这就是编译器在告诉汇编器"这段代码 / 数据进哪个段"; - 出现
.global(_main、_add_one等)------这是给链接器看的符号导出声明; - 局部变量
loc表现为栈 / 寄存器操作,不会 出现.global loc。
- RH850 指令助记符(如
- 关键点 :编译器已"看懂"代码,段归属和符号导出在这一步就定了;符号在这里第一次以"汇编符号"形态出现。
3.4 第 3 步:汇编(-c)
bash
ccrh850 -c main.s -o main.o
- 做了什么 :把汇编翻译成机器码,按
.section伪指令把内容装箱到对应段,产出 ELF 可重定位目标文件main.o。 - 验证方法 (用 GHS 工具
elfdump或 GNUobjdump):
bash
elfdump -s main.o
elfdump -r main.o
- 关键点 :
- 符号表里能查到
_main、_add_one、_g_val、_C_CONST等全局符号及其所在段、段内偏移; - 重定位表记录了"哪些位置需要链接期回填地址"(例如对
printf、对跨模块符号的引用); - 段在
.o内的组织已定,但所有地址仍是相对偏移,不是最终内存地址。
- 符号表里能查到
3.5 第 4 步:链接(link)
bash
ccrh850 main.o -o app.elf
- 做了什么 :符号解析 + 段合并 + 最终地址分配 ,生成可执行映像(GHS 链接器
gelfink)。 - 验证方法:
bash
elfdump -a app.elf
- 关键点 :
app.elf中_g_val、_C_CONST、_main、_add_one都被分配了确定的 Flash/RAM 物理地址,交叉引用已回填完毕。
3.6 一步到位与中间文件保留
实际工程通常一条命令编译 + 链接:
bash
ccrh850 main.c -o app.elf
若想保留各阶段中间文件便于排查,GHS 对应"保留临时文件"的选项(--keep / --save-temps 视版本而定):
bash
ccrh850 --keep main.c -o app.elf
用 -v / --verbose 可观察 GHS 驱动实际调用了哪些子工具(预处理器、编译器、汇编器、链接器),是理解"四阶段被驱动串起来"最直观的方式。

图 3:GHS 四步构建流程(.c → .i → .s → .o → .elf)
四、踩坑与优化总结:链接期符号问题的定位与规避
4.1 典型坑 1:undefined reference to "func"(未定义符号)
现象:链接阶段报错,提示某个符号未定义。
根因 :某处声明 / 引用了 func,但整个链接输入里没有任何 .o 定义了它。常见原因:
- 忘了把实现
func的.c加入编译 / 链接; - 声明了但未实现;
- 实现文件名拼错、路径没被链接器包含。
排查思路:
bash
elfdump -s app.elf | grep func
elfdump -s main.o | grep func
解决 :把定义 func 的 .o 加入链接;或补全实现。
4.2 典型坑 2:multiple definition of "g_val"(重复定义)
现象:链接报"多重定义"。
根因 :把全局变量定义(int g_val;)写进了头文件 ,又被多个 .c #include,导致每个翻译单元都定义了一次同名强符号。这是 C 新手最经典的坑。
根因图解 :一个全局变量一旦在头文件里定义,预编译期就被"复制"进每个包含它的 .c → 编译期产生多个强定义 → 链接期冲突。
解决 :头文件只放 extern int g_val; 声明,真正的定义(int g_val;)只放在唯一一个 .c 中。必要时用 static 让符号仅本文件可见。
4.3 典型坑 3:链接顺序影响符号解析
现象 :有时链接报未定义,调换 .o 顺序又好了。
根因 :GHS 链接器(以及多数 ELF 链接器)按输入顺序扫描库 ,被引用的符号如果定义在"已经被扫描完的库"里,可能解析不到。链接顺序对 .o 之间通常无关(都会全量参与),但对**静态库(.a)**非常敏感:被依赖的库要放在引用者之后。
解决 :库放在所有引用它的 .o 之后;必要时用分组选项(GHS 的 --start-group / --end-group,对应 GNU ld 同名选项)解决循环依赖。
4.4 典型坑 4:const 写不进、宏"改了没生效"
- const 写保护 :
const int C_CONST落在只读段(Flash),代码里C_CONST = 20在编译期就会被警告,运行期会触发写保护异常。若发现"写 const 竟然没报错",多半是用了强制类型转换绕过了类型系统,运行期仍可能崩溃。 - 宏"没生效" :因为宏在预编译期 就展开完了,改了
#define后若只重链接、不重新预编译,展开结果不会更新。务必让改动宏的源文件参与重新预编译。
4.5 工程最佳实践清单
- 符号纪律 :头文件只放
extern声明;全局变量"定义一处、声明多处";能用static隐藏的符号尽量加static,减少链接冲突面。 - 善用中间文件 :排查问题时保留中间产物,
main.i查宏、main.s查段归属和符号、main.o查段内偏移与重定位。 - 理解段与链接脚本 :段归属在编译期就定了(
.section伪指令),链接脚本只负责把这些段分配到 Flash/RAM 的具体地址。const/ 字符串落.rodata(Flash),已初始化全局落.data,未初始化落.bss(RAM)。 - 固定链接顺序:把库统一放到依赖链末尾,用分组选项处理环依赖,避免"换个顺序就编译不过"的隐性风险。

图 4:常见编译链接报错与解决办法对照
五、结尾收尾
回到开头的四个问题,现在都有了答案:undefined reference 是链接期符号解析失败的典型表现;multiple definition 源于"定义进了头文件"被多翻译单元复制;宏在预编译期 就已展开消失,改动后必须重新预编译;const 常量落在只读段(Flash),运行期不可写。
一句话总结:预编译做"文本代工",编译做"语义翻译 + 建符号表 + 定段归属",汇编做"机器码装箱 + 记录段内偏移",链接做"符号闭环 + 分配最终地址"。宏、局部变量、全局变量、const、函数这五类元素,从"文本→汇编符号→目标符号→最终地址"逐层递进,各自在不同阶段被引用、被处理。
进阶方向:想更深一层,可以继续研究------
把编译链路的每一环吃透,你排查 RH850 工程问题时的"定位半径"会成倍缩小。祝你在车规底层的路上越走越稳。