返回博客
嵌入式代码是怎么跑起来的?从 C 源码到机器码的完整编译流程

嵌入式代码是怎么跑起来的?从 C 源码到机器码的完整编译流程

2026年4月22日嵌入式, 编译原理, C语言

引言

每次在 Keil 或 VS Code 里点下"Build"按钮,几秒钟后就能把程序烧到芯片里运行。但这个过程中到底发生了什么?你写的 ifforprintf 是怎么变成 ARM Cortex-M 能执行的 0 和 1 的?

对于桌面开发来说,编译后的程序直接在本机 CPU 上运行,操作系统负责加载和调度。但嵌入式开发有一个本质区别:你在 x86 电脑上编译代码,生成的却是 ARM(或 RISC-V 等)架构的机器码——这就是所谓的交叉编译(Cross Compilation)。

理解编译流程不只是"知识补充"。在实际开发中,链接脚本配置错误会导致程序无法启动,优化等级选择不当会引入诡异 Bug,看不懂 map 文件就无法定位内存溢出。编译流程是连接"写代码"和"芯片跑起来"之间的桥梁。

本文以 ARM Cortex-M 平台和 GCC 工具链为例,完整拆解从 .c 源文件到芯片 Flash 中机器码的每一个步骤。

编译全景图

整个编译过程分为四个阶段,每个阶段的输入和输出都不同:

  源文件 (.c/.h)
       ↓  预处理 (Preprocessing)
  预处理文件 (.i)
       ↓  编译 (Compilation)
  汇编文件 (.s)
       ↓  汇编 (Assembly)
  目标文件 (.o)
       ↓  链接 (Linking)
  可执行文件 (.elf)
       ↓  格式转换
  烧录文件 (.bin/.hex)
       ↓  烧录
  芯片 Flash

对应的 GCC 工具链命令:

阶段工具输入输出选项
预处理arm-none-eabi-gcc -E.c.i-E
编译arm-none-eabi-gcc -S.i.s-S
汇编arm-none-eabi-as.s.o-c
链接arm-none-eabi-ld.o + .ld.elf-T
转换arm-none-eabi-objcopy.elf.bin/.hex-O binary

实际开发中,通常一条 gcc 命令就完成了前三个阶段,但理解每个阶段各自做了什么,对排查编译问题非常有帮助。

预处理(Preprocessing)

预处理是编译的第一步,它处理所有以 # 开头的指令,不涉及任何语法分析,本质上就是文本替换。

主要工作

头文件展开#include "stm32f4xx.h" 会把头文件的全部内容原封不动地插入到当前位置。一个看似只有 20 行的源文件,展开后可能有几万行。

宏替换#define LED_PIN 13 会把代码中所有 LED_PIN 替换为 13。函数式宏 #define MAX(a,b) ((a)>(b)?(a):(b)) 同理展开。

条件编译

#if (PMIC_DEVIECE_TYPE == 2)
    #include "sgm41511.h"
#endif

预处理器根据宏的值决定保留哪些代码、丢弃哪些。这在嵌入式中极为常见——同一套代码通过宏适配不同的硬件型号。

删除注释:所有 ///* */ 注释在这一步被清除。

查看预处理结果

arm-none-eabi-gcc -E main.c -o main.i

打开 main.i,你会看到一个庞大的纯 C 文件:所有头文件已展开、宏已替换、条件编译已解析。这对排查"宏展开结果是否符合预期"非常有用。

常见问题

  • 头文件循环包含:A.h include B.h,B.h include A.h,导致展开无限递归。解决方案是使用 #ifndef 头文件保护或 #pragma once
  • 宏展开不符合预期:函数式宏的参数没有加括号,导致运算优先级错误。例如 #define SQUARE(x) x*x,调用 SQUARE(1+2) 展开为 1+2*1+2 = 5 而不是 9

编译(Compilation)

编译阶段将预处理后的 C 代码翻译成汇编语言。这是整个流程中最复杂的一步,包含词法分析、语法分析、语义分析和代码优化。

编译器做了什么

  1. 词法分析:将代码拆分为 token(关键字、标识符、运算符、常量等)
  2. 语法分析:根据 C 语法规则构建抽象语法树(AST)
  3. 语义分析:检查类型匹配、变量是否声明、函数参数是否正确
  4. 中间代码生成:转换为编译器内部的中间表示(IR)
  5. 优化:根据优化级别对中间代码进行变换
  6. 目标代码生成:将优化后的 IR 翻译为目标平台的汇编指令

查看汇编输出

arm-none-eabi-gcc -S -mcpu=cortex-m4 -O0 main.c -o main.s

一个简单的 C 函数:

int add(int a, int b) {
    return a + b;
}

生成的 ARM Thumb-2 汇编(-O0 无优化):

add:
    push    {r7}           @ 保存帧指针
    sub     sp, sp, #12    @ 分配栈空间
    add     r7, sp, #0     @ 设置帧指针
    str     r0, [r7, #4]   @ 参数 a 存入栈
    str     r1, [r7, #0]   @ 参数 b 存入栈
    ldr     r2, [r7, #4]   @ 从栈加载 a
    ldr     r3, [r7, #0]   @ 从栈加载 b
    add     r3, r3, r2     @ r3 = a + b
    mov     r0, r3         @ 返回值放入 r0
    adds    r7, r7, #12    @ 恢复栈指针
    mov     sp, r7
    pop     {r7}           @ 恢复帧指针
    bx      lr             @ 返回

同一个函数在 -O2 优化下:

add:
    add     r0, r0, r1     @ r0 = a + b,直接返回
    bx      lr

从 13 条指令优化到 2 条——编译器发现参数已经在寄存器中,不需要压栈再弹栈。

优化级别

级别含义嵌入式场景
-O0不优化,便于调试开发调试阶段
-O1基本优化,不增加编译时间平衡选择
-O2较强优化,可能改变代码结构性能敏感场景
-Os优化代码体积(嵌入式常用)Flash 空间紧张时
-O3激进优化,可能增大代码体积嵌入式中较少使用

嵌入式项目中最常用的是 -Os(优化体积)和 -O0(调试模式)。很多诡异的 Bug 只在开启优化后出现,关闭优化就消失——这通常是代码本身存在未定义行为(如 volatile 遗漏、竞态条件等)。

汇编(Assembly)

汇编阶段将汇编语言翻译成机器码,生成目标文件(.o,也叫 object file)。

汇编器做了什么

汇编器的工作相对简单——把汇编指令一对一翻译成机器码:

arm-none-eabi-gcc -c main.c -o main.o

或者直接汇编 .s 文件:

arm-none-eabi-as main.s -o main.o

目标文件的结构

.o 文件是 ELF 格式(Executable and Linkable Format),包含:

  • .text:机器码(代码)
  • .data:已初始化的全局变量
  • .bss:未初始化的全局变量(不占文件空间,只记录大小)
  • .rodata:只读数据(常量字符串等)
  • 符号表:记录函数名、全局变量名及其地址(此时地址是相对的)
  • 重定位表:记录哪些地方引用了外部符号,需要链接器填入最终地址

查看目标文件

# 查看段信息
arm-none-eabi-objdump -h main.o

# 反汇编 .text 段
arm-none-eabi-objdump -d main.o

# 查看符号表
arm-none-eabi-nm main.o

objdump -d 的输出示例:

00000000 <add>:
   0:   b580        push    {r7, lr}
   2:   4403        add     r3, r0, r1
   4:   4618        mov     r0, r3
   6:   bd80        pop     {r7, pc}

注意左边的地址是从 0x00000000 开始的——因为还没有经过链接,不知道这个函数最终会被放在 Flash 的哪个位置。

关键概念:符号(Symbol)

每个函数名和全局变量名都是一个符号。在 .o 文件中:

  • 定义的符号:在本文件中实现了的函数或变量(如 add 函数)
  • 未定义的符号:在本文件中引用了但没有实现的(如调用了 printf,但 printf 的实现在其他 .o 或库中)

链接器的任务之一就是把所有"未定义符号"和"定义符号"匹配起来。

链接(Linking)

链接是编译流程中最关键也最容易出问题的一步。它把多个 .o 文件和库文件合并成一个完整的可执行文件(.elf)。

链接器做了什么

  1. 符号解析:扫描所有 .o 文件,将每个"未定义符号"匹配到某个 .o 中"已定义的符号"。如果找不到,就是 undefined reference 错误;如果同一个符号被多个 .o 定义,就是 multiple definition 错误
  2. 段合并:把所有 .o.text 段合并为一个大的 .text 段,.data 段合并为一个大的 .data 段,以此类推
  3. 地址重定位:根据链接脚本分配最终地址,把所有函数调用、全局变量引用的相对地址替换为绝对地址

链接脚本(.ld)

这是嵌入式编译区别于桌面编译的核心。桌面程序由操作系统负责内存布局,而嵌入式没有操作系统,需要通过链接脚本手动指定代码和数据放在哪里。

一个简化的 nRF52832 链接脚本:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x00000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}

SECTIONS
{
    .text :
    {
        KEEP(*(.isr_vector))    /* 中断向量表,必须放在 Flash 开头 */
        *(.text*)               /* 所有代码 */
        *(.rodata*)             /* 只读数据 */
    } > FLASH

    .data :
    {
        _sdata = .;
        *(.data*)               /* 已初始化全局变量 */
        _edata = .;
    } > RAM AT> FLASH           /* 运行在 RAM,加载在 Flash */

    .bss :
    {
        _sbss = .;
        *(.bss*)                /* 未初始化全局变量 */
        *(COMMON)
        _ebss = .;
    } > RAM

    _estack = ORIGIN(RAM) + LENGTH(RAM);  /* 栈顶地址 */
}

关键点:

  • FLASH 区域0x00000000):存放代码和常量,掉电不丢失
  • RAM 区域0x20000000):存放变量和栈,掉电丢失
  • .data 段的双重归属> RAM AT> FLASH 表示变量运行时在 RAM 中,但初始值存储在 Flash 中。启动时由 startup 代码从 Flash 拷贝到 RAM
  • _sdata/_edata/_sbss/_ebss:这些符号供 startup 代码使用,用于初始化 .data 段和清零 .bss
  • _estack:栈顶地址,芯片复位后第一个读取的值

startup.s:真正的入口

嵌入式程序的入口不是 main(),而是中断向量表和 Reset_Handlerstartup.s(或 startup.c)做了以下工作:

  1. 定义中断向量表(放在 Flash 开头,.isr_vector 段)
  2. Reset_Handler:芯片上电/复位后第一个执行的函数
  3. .data 段从 Flash 拷贝到 RAM(已初始化全局变量)
  4. .bss 段清零(未初始化全局变量按 C 标准为 0)
  5. 调用 SystemInit()(配置时钟等)
  6. 跳转到 main()
Reset_Handler:
    ldr   r0, =_sdata       @ RAM 中 .data 起始地址
    ldr   r1, =_edata       @ RAM 中 .data 结束地址
    ldr   r2, =_sidata      @ Flash 中 .data 的加载地址
copy_data:
    cmp   r0, r1
    bge   zero_bss
    ldr   r3, [r2], #4
    str   r3, [r0], #4
    b     copy_data

zero_bss:
    ldr   r0, =_sbss
    ldr   r1, =_ebss
    movs  r2, #0
clear_bss:
    cmp   r0, r1
    bge   call_main
    str   r2, [r0], #4
    b     clear_bss

call_main:
    bl    SystemInit
    bl    main
    b     .                  @ main 返回后死循环(不应发生)

这就是为什么全局变量 int g_count = 100;main() 执行时已经有了初始值——Reset_Handler 在进入 main 之前就把 100 从 Flash 拷贝到了 RAM。

嵌入式特有环节

ELF → BIN/HEX 转换

链接器输出的 .elf 文件包含调试信息、符号表等元数据,芯片无法直接执行。需要转换为纯二进制格式:

# 转为 BIN(纯二进制,按地址顺序排列)
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin

# 转为 HEX(Intel HEX 格式,包含地址信息)
arm-none-eabi-objcopy -O ihex firmware.elf firmware.hex
格式特点使用场景
.elf包含代码、数据、调试信息、符号表调试器使用(GDB、J-Link)
.bin纯二进制,无地址信息DFU 升级、部分烧录工具
.hexASCII 文本,包含地址信息Keil 下载、J-Flash

烧录到 Flash

通过 J-Link、ST-Link 等调试器,将 .bin.hex 写入芯片的 Flash 存储器。烧录工具根据芯片型号,控制 Flash 的擦除和编程时序。

启动流程

芯片上电后的完整执行路径:

上电 / 复位
    ↓
从 Flash 地址 0x00000000 读取栈顶指针 → 写入 SP 寄存器
    ↓
从 Flash 地址 0x00000004 读取 Reset_Handler 地址 → 跳转执行
    ↓
Reset_Handler: 拷贝 .data、清零 .bss、调用 SystemInit
    ↓
跳转到 main()
    ↓
应用代码开始运行

这就是为什么链接脚本中 .isr_vector 必须放在 Flash 开头——芯片复位后第一件事就是从 0x00000000 读取栈顶和入口地址。

实战:用 GCC 手动走一遍

以一个最简单的 LED 闪烁程序为例,手动执行编译的每个阶段。

源代码

// main.c
#include <stdint.h>

#define LED_PIN     13
#define GPIO_BASE   0x50000000
#define GPIO_OUTSET (*(volatile uint32_t *)(GPIO_BASE + 0x508))
#define GPIO_OUTCLR (*(volatile uint32_t *)(GPIO_BASE + 0x50C))
#define GPIO_DIRSET (*(volatile uint32_t *)(GPIO_BASE + 0x518))

void delay(volatile uint32_t count)
{
    while (count--) {}
}

int main(void)
{
    GPIO_DIRSET = (1 << LED_PIN);

    while (1)
    {
        GPIO_OUTSET = (1 << LED_PIN);
        delay(500000);
        GPIO_OUTCLR = (1 << LED_PIN);
        delay(500000);
    }
}

逐步执行

# 1. 预处理:展开头文件和宏
arm-none-eabi-gcc -E -mcpu=cortex-m4 main.c -o main.i
# 查看 main.i,LED_PIN 已被替换为 13,GPIO_BASE 已展开为地址

# 2. 编译:C → 汇编
arm-none-eabi-gcc -S -mcpu=cortex-m4 -mthumb -Os main.c -o main.s
# 查看 main.s,可以看到 ARM Thumb-2 汇编指令

# 3. 汇编:汇编 → 目标文件
arm-none-eabi-gcc -c -mcpu=cortex-m4 -mthumb -Os main.c -o main.o
# 查看符号:arm-none-eabi-nm main.o

# 4. 链接:目标文件 → ELF(需要链接脚本和 startup)
arm-none-eabi-gcc main.o startup.o -T linker.ld -nostdlib -o firmware.elf
# -nostdlib:不链接标准库(裸机环境)
# -T linker.ld:指定链接脚本

# 5. 转换:ELF → BIN
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin

# 6. 查看最终结果
arm-none-eabi-objdump -d firmware.elf    # 反汇编
arm-none-eabi-size firmware.elf          # 查看各段大小
arm-none-eabi-nm firmware.elf            # 查看符号地址

size 输出解读

   text    data     bss     dec     hex filename
   1024      12       4    1040     410 firmware.elf
  • text(1024 字节):代码 + 只读数据,占用 Flash
  • data(12 字节):已初始化全局变量,占用 Flash(存储初始值)+ RAM(运行时)
  • bss(4 字节):未初始化全局变量,只占用 RAM
  • Flash 占用 = text + data = 1036 字节
  • RAM 占用 = data + bss = 16 字节(不含栈和堆)

常见编译错误与排查

undefined reference

main.o: undefined reference to `sgm41511_init'

原因:链接器在所有 .o 文件和库中都找不到 sgm41511_init 的定义。

排查

  • 忘了把 sgm41511.c 加入编译
  • 函数名拼写不一致(C++ 有 name mangling,需要 extern "C"
  • 函数声明了但没有实现

multiple definition

sgm41511.o: multiple definition of `g_charge_status'
pmic.o: first defined here

原因:同一个全局变量在多个 .c 文件中被定义。

排查

  • 全局变量应在 .c 中定义,头文件中只用 extern 声明
  • 头文件中不要写 int g_charge_status = 0;

section overflow

region `FLASH' overflowed by 2048 bytes

原因:代码或数据超出了链接脚本中定义的 FLASH 或 RAM 大小。

排查

  • arm-none-eabi-size 查看各段大小
  • -Os 优化代码体积
  • 检查是否有未使用的大数组或大型 const
  • 检查第三方库是否引入了过多代码

Hard Fault 与编译的关系

Hard Fault 是 ARM Cortex-M 最常见的异常。虽然通常是运行时错误,但很多根因和编译有关:

  • 栈溢出:链接脚本中栈空间分配不足,局部变量过大
  • 访问非法地址:指针未初始化,.bss 段没有正确清零
  • 函数指针为空:中断向量表中某个 Handler 没有实现,跳转到 0 地址
  • 对齐错误:某些 ARM 指令要求地址对齐,编译器通常会处理,但手动操作内存时需注意

编译知识在代码调试中的应用

理解编译流程不只是理论知识——它在日常调试中有很多实际用途。

用 map 文件定位内存问题

编译时加上 -Wl,-Map=firmware.map 生成 map 文件。它记录了每个函数和变量的最终地址和大小:

.text           0x00000000     0x1a4
 *(.isr_vector)
 .isr_vector    0x00000000      0xc0 startup.o
 *(.text*)
 .text.main     0x000000c0      0x48 main.o
 .text.delay    0x00000108      0x14 main.o
 .text.sgm41511_init
                0x0000011c      0x58 sgm41511.o

.bss            0x20000000     0x120
 .bss.rx_buffer 0x20000000     0x100 uart.o
 .bss.g_status  0x20000100      0x04 main.o

当你遇到 region 'RAM' overflowed 时,打开 map 文件找到占用最大的变量——往往一眼就能看到有人定义了一个 uint8_t buffer[4096] 之类的大数组。

用 objdump 反汇编分析 Hard Fault

当程序触发 Hard Fault 时,调试器通常会给出出错的 PC(程序计数器)地址。通过反汇编定位出错的 C 代码行:

# 反汇编整个 ELF
arm-none-eabi-objdump -d -S firmware.elf > firmware.asm

# 如果 Hard Fault 时 PC = 0x00001234,在 firmware.asm 中搜索该地址
# 加了 -S 选项时,反汇编会穿插 C 源码行,直接看到对应的 C 代码

如果编译时加了 -g(调试信息),还可以用 addr2line 直接查到源文件和行号:

arm-none-eabi-addr2line -e firmware.elf 0x00001234
# 输出: /src/sgm41511.c:47

优化等级导致的调试异常

这是嵌入式开发中最常见的"灵异事件"之一:

现象 1:断点打不住

int result = compute_value();
// 在这里打断点,调试器说 "breakpoint at optimized-out location"
return result;

编译器在 -O2 下可能将 result 变量优化掉,直接用寄存器传递返回值。解决方案:调试时切换到 -O0,或者将关键变量声明为 volatile

现象 2:变量值"错乱"

volatile uint32_t *status_reg = (volatile uint32_t *)0x40001000;
uint32_t status = *status_reg;  // 调试器显示 status 值和预期不符

如果忘记 volatile,编译器可能缓存读取结果,甚至完全移除"看似无意义"的读操作。这在硬件寄存器访问中是致命的。

现象 3:代码被优化消失

void delay_us(uint32_t us)
{
    for (uint32_t i = 0; i < us * 10; i++) {
        // 空循环
    }
}

-O2 下,编译器认为这个循环没有副作用(不改变任何可观察状态),直接把整个函数体删除了。解决方案:循环变量加 volatile,或使用 __NOP() 等内联汇编。

利用符号表辅助 Crash 分析

在量产设备中无法连接调试器时,通常会在 Hard Fault Handler 中打印 PC、LR、SP 等寄存器值。拿到这些地址后:

# 查看该地址附近的符号
arm-none-eabi-nm -n firmware.elf | grep -B1 "00001234"

# 或者查看该地址属于哪个函数
arm-none-eabi-addr2line -f -e firmware.elf 0x00001234
# 输出:
# sgm41511_write_reg
# /src/sgm41511.c:47

结合 map 文件和符号表,即使没有调试器也能大致定位 Crash 原因——这在量产运维中非常实用。

用 readelf 检查 ELF 头信息

当出现"程序能编译但烧录后不运行"的情况时,检查 ELF 头可以排除一些低级错误:

arm-none-eabi-readelf -h firmware.elf
  Class:                  ELF32
  Machine:                ARM
  Entry point address:    0x00000c1
  Flags:                  0x5000400, ...

检查 Entry point 是否指向 Reset_Handler,Machine 是否为 ARM,这些信息和芯片是否匹配。

总结

从 C 源码到芯片运行,完整的编译流程可以归纳为:

.c 源文件
  → 预处理(宏展开、头文件包含、条件编译)
  → 编译(C → 汇编,类型检查、代码优化)
  → 汇编(汇编 → 机器码,生成 .o 目标文件)
  → 链接(合并 .o、符号解析、地址重定位,生成 .elf)
  → 格式转换(.elf → .bin/.hex)
  → 烧录到 Flash
  → 芯片上电,从 Reset_Handler 开始执行

关键要点

  • 预处理是纯文本操作,用 -E 可以排查宏展开问题
  • 编译阶段做优化,优化级别直接影响代码大小、执行速度和可调试性
  • 链接脚本是嵌入式编译的核心配置,决定了代码和数据在 Flash/RAM 中的布局
  • startup 代码main() 之前执行,负责初始化 C 运行环境(拷贝 .data、清零 .bss)
  • map 文件objdumpaddr2line 是排查内存溢出和 Crash 的利器
  • 编译相关的知识不是"选修课"——volatile 遗漏、栈溢出、链接脚本错误等问题,只有理解编译流程才能从根本上解决