← 返回嵌入式学习资料第 2 章 · C、编译与链接

CHAPTER 2 · C BUILD SYSTEMS

C、编译与链接
从源文件到内存布局

从多文件 C 对象开始,沿着预处理、编译、汇编、链接和启动一路追踪;读懂 ELF、map 文件和 ABI,让构建结果成为可复查的地址与行为证据。

6 个一级单元52 个递进小节主机路径可复现

TEXTBOOK MAP

CHAPTER 2 · MAP

从源代码到可审阅产物

六个一级单元对应六种构建认知:定义对象、拆开阶段、画像内存、阅读链接、接上启动、完成交付。

先把 C 对象说清楚
  • 01 翻译单元与接口合同
  • 02 编译流水线与诊断
再把地址证据串起来
  • 03 存储期与内存画像
  • 04 链接器、ELF 与 map
最后接上真实启动
  • 05 ABI、向量表与 Cortex-M
  • 06 构建交付与复现边界

小节数量按认知任务变化:流水线、内存、链接和交付需要更多递进步骤;每个单元都保留无目标机主机路径。

使用方式:先从左侧选择一个构建认知,再按小节中的概念、阶段、证据和练习推进。每个单元底部有前后翻页;小节标题直接说明正在追踪的对象或产物。
主机边界:默认使用标准 C 工具链或实验包模型;没有目标板也能完成对象合同、阶段化构建、ELF/map 阅读和交付记录。
目标机边界:真实地址、启动、ABI、Flash/RAM、供电和行为必须回到当前芯片、板卡、启动文件、链接脚本和工具链的官方资料。

01 · C OBJECT MODEL

C 的对象模型:先把源文件说清楚

C 程序不是一整块文本。预处理器、编译器、链接器和启动代码会分别看见不同的对象;声明、定义、作用域、链接属性和类型合同如果没有写清楚,编译器可能暂时放行,链接器或目标机才暴露问题。

看懂区分翻译单元、声明、定义和符号。
说清用作用域、链接属性和类型描述接口。
开始做为多文件 C 工程留下可复查的对象合同。
官方基线Arm GNU Toolchain编译、链接、ELF/map 和工具输出按课程锁定工具链复现。锁定:15.2.rel1 · 核对日期:2026-09-05

2.1.1 翻译单元:编译器真正看到什么

翻译单元的边界决定了宏和静态对象的可见范围。两个 .c 文件即使包含同一份头文件,也会各自完成预处理和编译;编译器在当前单元里看不到另一个文件的局部变量。因而‘整个工程里有这个名字’不是一个足够精确的描述,必须说明它在哪个翻译单元被定义、以何种链接属性导出,以及调用者通过哪一份声明使用它。

2.1.2 声明与定义:名字出现不等于对象存在

声明是一种承诺,定义是一种落地。extern int counter; 只告诉当前翻译单元存在一个外部对象,不会分配 counter 的存储;int counter; 在文件作用域提供定义,而 static int counter; 同时提供定义并把符号限制在本文件。把两种文本混在头文件中,往往会让每个包含者都生成一个对象,最终出现重复定义或状态被悄悄复制。

C · HEADER CONTRACT
#ifndef SENSOR_H #define SENSOR_H #include <stddef.h> #include <stdint.h> int sensor_read(uint8_t *dst, size_t capacity); #endif

头文件只提供声明和类型合同;它不应在每个包含者中定义同一个可变对象。

C · ONE DEFINITION
#include "sensor.h" static uint8_t last_value; int sensor_read(uint8_t *dst, size_t capacity) { if (dst == NULL || capacity == 0) return 0; *dst = last_value; return 1; }

static 把实现细节留在当前翻译单元;真实模块还应补充错误、并发和初始化合同。

2.1.3 作用域与链接:名字能被谁看见

接口合同至少包含名字、类型、生命周期、所有权和错误行为。一个返回指针的函数还要说明指针是否允许为空、指向的长度在哪里、调用者是否可以修改内容,以及对象何时失效。只在函数名旁边写‘获取数据’并不足以支撑迁移;下一位读者需要从头文件和测试中知道边界,而不是从一次成功调用中猜测。

表 1 · C 的对象模型:先把源文件说清楚的证据判断表
对象应留下的证据不能直接推出
声明给当前单元提供类型与名字自动分配一次存储
定义提供函数体或对象存储只能写在头文件
链接属性决定名字可见范围等同于运行时权限

2.1.4 类型合同:同名接口也可能不兼容

const 修饰的是访问合同,不是装饰。const uint8_t * 表示调用者不能通过该指针修改数据,uint8_t * const 表示指针本身不能改动,二者在 API 评审时应分别解释。嵌入式代码还要把寄存器、DMA 缓冲区和中断共享对象的可见性单独记录,不能因为主机测试通过就把普通对象当成硬件同步对象。

2.1.5 指针与数组:表达式相似,边界不同

数组在函数参数位置会退化为指针,因此 void copy(uint8_t dst[8], const uint8_t src[8]) 并没有自动携带长度合同。更稳妥的接口会显式写 size_t length,并决定长度为零、重叠区域和越界输入如何处理。这样的字段既能让编译器检查一部分类型,也能让测试把剩余的行为边界固定下来。

图 1.1 · C 的对象模型:先把源文件说清楚的判断路径每一格都要有自己的条件。
源文件.c + 展开后的头文件
对象声明、定义、类型
符号内部/外部链接
接口调用与证据

从左到右是思考顺序,不是把四类证据合并成一个“通过”。

2.1.6 结构体、枚举与位域:把数据布局写成接口

结构体布局是语言语义与 ABI 的交汇点。字段顺序会影响对齐和 sizeof,编译器选项、目标架构和打包属性可能改变二进制布局;因此把结构体直接当作通信帧或 Flash 镜像时,必须留下静态断言、字节序说明和版本字段。‘在我的电脑上 sizeof 是某个值’不能替代目标 ABI 的证据。

2.1.7 公开 API 与私有实现:减少符号表面积

静态函数并不是‘优化提示’,它是模块边界。把只服务于一个 .c 文件的解析器、状态机辅助函数和缓存标记为 static,可以减少符号冲突并让链接器有机会删除未使用代码。公开符号越少,链接图越容易阅读;但如果误把需要被启动文件调用的入口设为 static,错误会在链接阶段表现为未定义引用。

2.1.8 本节交付:一张对象合同卡

对象合同卡的价值在于把代码文本之外的条件留下来。它应包含定义所在文件、公开声明、初始化时机、并发访问、失败返回和验证命令;若对象会映射到寄存器、DMA 或 Flash,还应附上数据手册、链接脚本或 map 文件的出处。交付不是把 .c 文件发出去,而是让下一位读者知道怎样判断它被正确使用。

CHECKPOINT 2.1把“一个变量、一个头文件和一个函数,究竟在哪个边界上存在?”写成一份可复查记录

请围绕“C 的对象模型:先把源文件说清楚”写出对象、输入或构建目标、状态/机制、输出、一个约束、一个失败路径和对应证据。没有目标板时,运行主机路径并把未执行字段标为未执行;拥有目标机时,也要补齐工具链、链接脚本、板卡、供电、启动和观察条件。不要用一次构建成功或一个 bin 文件覆盖没有做过的验证。

合格记录要让没有现场上下文的读者知道先查什么、先运行什么、看到什么才算通过,以及哪一句结论仍然不能推出。未知项和停止条件不是扣分项,而是交付的一部分。

02 · COMPILER PIPELINE

编译流水线:从预处理到可执行文件

C 的构建是一个有方向的转换链:预处理把文本合同展开,编译把语法和类型变成汇编,汇编产生目标文件,链接把多个目标和库组合成最终映像。每一步都可以独立保存、比较和复现,不能只看最后一个绿色提示。

看懂能按阶段解释 gcc/clang 命令。
说清让警告、依赖和工具版本成为构建证据。
开始做用最小命令定位预处理、编译或链接失败。
官方基线Arm GNU Toolchain编译、链接、ELF/map 和工具输出按课程锁定工具链复现。锁定:15.2.rel1 · 核对日期:2026-09-05

2.2.1 预处理:宏和头文件先改变文本

gcc 是驱动程序,不是只有一个动作的编译器按钮。根据选项和输入后缀,它可能调用预处理器、编译器后端、汇编器与链接器;-E、-S、-c 分别让流程停在不同阶段。保存这些中间产物可以把‘代码不对’拆解成‘文本展开不对’、‘类型诊断不对’、‘目标文件缺符号’或‘地址布局不对’。

2.2.2 编译:类型检查与优化在这里发生

警告的价值取决于规则是否稳定。-Wall 并不等于所有警告,-Wextra、-Wconversion、-Werror 等选项表达的是项目对风险的选择。读者应保存完整选项而不是只写‘开启警告’,并说明某条警告为何被修复、抑制或保留;否则不同工具链的默认行为会让同一份代码看起来产生不同质量。

SHELL · STAGE THE BUILD
cc -std=c17 -Wall -Wextra -E src/main.c -o build/main.i cc -std=c17 -Wall -Wextra -S build/main.i -o build/main.s cc -std=c17 -Wall -Wextra -c build/main.s -o build/main.o cc build/main.o -o build/main

阶段化命令让读者保存预处理、汇编和目标文件;将 cc 替换为目标工具链时必须记录三元组和选项。

SHELL · KEEP EVIDENCE
cc --version > build/tool-version.txt cc -### src/main.c 2> build/driver-trace.txt sha256sum build/main.o build/main > build/artifacts.sha256

不同系统的校验命令可能不同;这里的重点是保留工具身份、驱动展开和产物校验,而不是依赖一台作者机器。

2.2.3 汇编与目标文件:机器代码仍未完成

目标文件中的函数地址通常还没有最终意义。编译器可以生成对外部函数的重定位记录,链接器随后根据符号定义和链接脚本为它填入地址。因而单独运行 objdump 看到的跳转目标不能自动回答‘固件运行时会跳到哪里’,要把输入对象、链接脚本、重定位和最终 ELF 一起作为证据。

表 2 · 编译流水线:从预处理到可执行文件的证据判断表
对象应留下的证据不能直接推出
预处理文本、宏、include已经完成类型检查
编译/汇编诊断、指令、.o已经决定最终地址
链接符号、节、ELF已经证明目标机启动

2.2.4 链接:把名字和地址兑现

链接错误是关系错误而不一定是语法错误。undefined reference 表示当前链接集合里没有满足该名字的定义;multiple definition 表示同一个外部符号有多个定义;cannot find -lX 则是库搜索路径或命名约定没有满足。保留原始命令和输入顺序,通常比反复改代码更快找到缺口。

2.2.5 警告与错误:诊断是交付的一部分

增量构建依赖依赖图,而不是依赖直觉。头文件变化可能使很多翻译单元失效,生成文件缺少依赖声明则会让旧目标文件被错误复用。最小工程应能用一次 clean build 建立基线,再用单变量修改验证只重建必要节点;这也是后续 CI 和故障复盘的基础。

图 2.1 · 编译流水线:从预处理到可执行文件的判断路径每一格都要有自己的条件。
文本宏与 include
编译类型与汇编
目标.o 与重定位
链接ELF 与段

从左到右是思考顺序,不是把四类证据合并成一个“通过”。

2.2.6 依赖图:为什么只改一个头文件会重编译很多文件

目标三元组改变了预定义宏、指针宽度、ABI、默认库和可用指令集。主机上通过的 size_t 比较、结构体布局或浮点行为不能未经说明地推广到 Cortex-M。构建记录中至少要留下编译器版本、目标三元组、sysroot、CPU/FPU 选项和链接脚本来源。

2.2.7 工具链与选项:默认值也必须被记录

量与单位:对本章的编译流水线:从预处理到可执行文件来说,预处理、编译、汇编、链接、目标文件、依赖、警告、优化、调试信息与构建图组成一条从源代码到地址和行为的证据链:先确认事实,再选择模型或工具,随后记录产物,最后写出覆盖范围。类型、符号、段和地址只有在目标、ABI、脚本、版本和构建选项明确时才有可解释性。

工具条件:在gcc/clang 驱动、预处理文件、汇编文件、.o 目标文件和最终 ELF中,比较主机 gcc 与 arm-none-eabi-gcc 的目标差异可能经过多个阶段。文本会被展开,单元会被编译,符号会被重定位,启动代码还可能改变内存内容;所以成功路径和失败路径都要写出来。把“应该能编译”改成带命令、输入和观察方式的句子,读者才有机会复现。

可重复不是二进制字节必须永远相同,而是差异可解释。调试路径、构建时间、归档成员顺序和绝对路径可能影响 ELF;若交付只需要功能测试,应该固定输入和测试结果;若还需要供应链审计,则应进一步固定工具版本、归档策略、产物清单和校验算法。

2.2.8 可重复构建:同一输入要有可解释差异

一条阶段化流水线让读者有机会在没有 IDE 的机器上复现。每一步都应写出当前目录、输入文件、输出文件、成功条件和失败处理;目标机专用命令另列为可选路径。把‘点击 Build’翻译成可审阅命令,不是为了排斥 IDE,而是为了让 IDE 的行为也能被解释。

2.2.9 本节交付:一条阶段化构建流水线

构建证据应能回答四个问题:使用了什么输入,调用了什么工具,生成了什么产物,哪一个条件还没有验证。最后一个问题尤其重要;一个 ELF 生成成功不等于烧录、启动、时钟、外设和合同行为已经成立。

CHECKPOINT 2.2把“一条 gcc 命令到底做了哪些阶段,哪一步产生了哪一种证据?”写成一份可复查记录

请围绕“编译流水线:从预处理到可执行文件”写出对象、输入或构建目标、状态/机制、输出、一个约束、一个失败路径和对应证据。没有目标板时,运行主机路径并把未执行字段标为未执行;拥有目标机时,也要补齐工具链、链接脚本、板卡、供电、启动和观察条件。不要用一次构建成功或一个 bin 文件覆盖没有做过的验证。

合格记录要让没有现场上下文的读者知道先查什么、先运行什么、看到什么才算通过,以及哪一句结论仍然不能推出。未知项和停止条件不是扣分项,而是交付的一部分。

03 · MEMORY IMAGE

内存与存储期:代码最后会落在哪里

在嵌入式系统里,‘变量’不只是名字和值,还包含存储期、可写性、对齐、段位置、启动初始化和运行时预算。把 .text、.rodata、.data、.bss、栈和堆分开,才能解释 map 文件和故障,而不是只看 RAM 还剩多少。

看懂区分存储期、可写性和内存段。
说清从 map/size/符号证据反推预算。
开始做识别栈、堆、对齐与 volatile 的边界。
官方基线Arm GNU Toolchain编译、链接、ELF/map 和工具输出按课程锁定工具链复现。锁定:15.2.rel1 · 核对日期:2026-09-05

2.3.1 存储期:对象何时出生、何时消失

存储期回答‘对象存在多久’,链接段回答‘对象映像放在哪里’,运行时地址回答‘这次启动后它在哪里’。三者有关联但不是同一个问题。一个 static 局部对象可以拥有整个程序生命周期,却可能进入 .data 或 .bss;一个自动局部数组只在调用期间存在,通常来自栈。审阅时要把三个问题分别写出来。

2.3.2 四个常见段:代码、常量与初始化数据

.data 中的对象通常有一份 Flash 初始映像,启动代码把它复制到 RAM;.bss 则只需要知道大小并在启动时清零。这个过程解释了为什么一个新增的非零全局数组同时增加 Flash 和 RAM,而一个全零或未初始化数组主要增加 RAM。具体段名和启动实现仍需以目标工具链为准。

C · STORAGE CLASSES
#include <stdint.h> const uint32_t lookup[4] = {1, 2, 4, 8}; uint32_t initialized = 7; uint32_t zeroed; static uint8_t file_cache[16]; int add(uint32_t value) { uint32_t local = value + initialized; return (int)local; }

源码只能提出存储与可写性意图;最终段位置、对齐和初始化仍要由 ELF/map 与目标规格核对。

SHELL · READ THE IMAGE
size build/firmware.elf nm -S --size-sort build/firmware.elf | tail -n 20 objdump -h build/firmware.elf

size、nm、objdump 的选项在工具链间可能略有差异;保存原始输出和版本,才能解释预算变化。

2.3.3 启动初始化:.data 拷贝与 .bss 清零

const 不是物理存储位置的保证。编译器可能把只读对象放入只读段,也可能因为地址取用、ABI 或链接脚本规则产生其他安排;目标机还可能把某些区域映射为可执行或特殊存储。可靠交付要看最终 ELF 的段属性和符号地址,而不是只看源代码中的 const 关键字。

表 3 · 内存与存储期:代码最后会落在哪里的证据判断表
对象应留下的证据不能直接推出
.text/.rodata代码与只读输入所有 const 永远在 Flash
.data/.bssRAM 对象与启动初始化全局变量没有运行时成本
栈/堆调用与动态生命周期空间可以无限增长

2.3.4 栈:调用深度和局部对象的上界

量与单位:对本章的内存与存储期:代码最后会落在哪里来说,自动/静态/动态存储期、.text、.rodata、.data、.bss、栈、堆、对齐、volatile 与内存预算组成一条从源代码到地址和行为的证据链:先确认事实,再选择模型或工具,随后记录产物,最后写出覆盖范围。类型、符号、段和地址只有在目标、ABI、脚本、版本和构建选项明确时才有可解释性。

工具条件:在Flash/RAM 分区、全局对象、局部变量、栈、堆、常量表与启动清零中,为一个递归或大数组案例写出停止条件与替代设计可能经过多个阶段。文本会被展开,单元会被编译,符号会被重定位,启动代码还可能改变内存内容;所以成功路径和失败路径都要写出来。把“应该能编译”改成带命令、输入和观察方式的句子,读者才有机会复现。

栈预算不能只拿 main 的局部变量相加。函数调用会保存返回地址和寄存器,中断可能嵌套,编译优化会改变帧布局,库函数还可能隐藏临时对象。工程上应结合最深调用路径、编译选项、栈填充水位或静态分析,并把测量条件和未覆盖路径写明。

2.3.5 堆与动态分配:可用不等于可控

堆的风险不仅是‘有没有内存’,还包括分配时机、碎片、失败后的恢复和并发保护。一个短测试中 malloc 成功,不能推出设备长时间运行不会失败。固定大小内存池、所有权明确的消息缓冲区和编译期数组通常更容易留下上界;若必须使用堆,应定义失败合同和监控证据。

图 3.1 · 内存与存储期:代码最后会落在哪里的判断路径每一格都要有自己的条件。
源级存储期与限定符
链接段与符号
启动拷贝/清零
运行栈与访问

从左到右是思考顺序,不是把四类证据合并成一个“通过”。

2.3.6 对齐与布局:地址也有合同

对齐错误可能在主机上沉默,在某些 Cortex-M 配置或外设总线上触发异常。packed 属性可以压缩布局,却可能引入未对齐访问和额外指令;因此通信帧应优先采用显式字节读写或经过验证的序列化,而不是把任意结构体直接强制转换成线上的字节流。

2.3.7 const、volatile 与原子性:三个词不能互换

volatile 只告诉编译器每次访问都可能有外部变化,不能保证多个线程或中断之间的复合操作原子,也不能提供内存排序。寄存器访问、状态标志和队列并不是同一个问题;正确选择可能是临界区、原子类型、内存屏障或驱动 API,而不是给所有字段加 volatile。

2.3.8 预算证据:从 map 文件找出真正的成本

内存画像的最小证据包括编译命令、目标三元组、链接脚本、size 输出、map 文件摘要和对象变更。读者应能回答‘增加 100 个字节后哪一段变大’、‘启动时谁初始化它’和‘剩余空间如何计算’。如果只有一个最终数字,没有对象级差异,就很难解释下一次回归。

2.3.9 本节交付:一份内存画像

预算交付也要记录未知项,例如未测量的最大中断嵌套、未覆盖的异常路径、未确认的外部 RAM 和未验证的温度条件。把未知写出来不会降低工程可信度,反而避免把一次静态构建结果误报成所有运行状态的长期保证。

CHECKPOINT 2.3把“一个 C 对象的生命周期、段位置和运行时成本怎样被证明?”写成一份可复查记录

请围绕“内存与存储期:代码最后会落在哪里”写出对象、输入或构建目标、状态/机制、输出、一个约束、一个失败路径和对应证据。没有目标板时,运行主机路径并把未执行字段标为未执行;拥有目标机时,也要补齐工具链、链接脚本、板卡、供电、启动和观察条件。不要用一次构建成功或一个 bin 文件覆盖没有做过的验证。

合格记录要让没有现场上下文的读者知道先查什么、先运行什么、看到什么才算通过,以及哪一句结论仍然不能推出。未知项和停止条件不是扣分项,而是交付的一部分。

04 · LINKER AND ELF

链接器与 ELF:把符号、段和地址对上

编译器负责把单个翻译单元变成目标文件,链接器负责把多个对象、库和启动入口放进一个地址空间。ELF、符号表、重定位、归档库、链接脚本和 map 文件共同构成可审阅的地址证据;它们比‘能生成 bin’更接近真实问题。

看懂会从符号表和 map 文件定位对象。
说清能读懂链接脚本的内存区与段规则。
开始做解释库顺序、弱符号和入口点的边界。
官方基线Arm GNU Toolchain编译、链接、ELF/map 和工具输出按课程锁定工具链复现。锁定:15.2.rel1 · 核对日期:2026-09-05

2.4.1 符号表:名字、绑定与地址

符号是链接器建立关系的名字,但同一个名字在不同绑定和节类型下含义不同。nm 中的大写 T、D、B、R 等标记通常能提示代码、已初始化数据、未初始化数据和只读对象;具体显示由工具实现决定。交付时应保存命令、工具版本和原始输出,不要只抄一个地址。

2.4.2 节与段:输入如何被归并

ELF section 更像输入和组织单位,segment 更接近加载器需要的权限和地址视图。一个 segment 可以包含多个 section,链接脚本中的排序、对齐和属性会影响最终文件。把 section 名称直接等同于芯片物理区域会隐藏这个中间步骤,因此需要同时看脚本、ELF 节表和目标内存规格。

LD · SMALL MEMORY CONTRACT
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K RAM (rwx): ORIGIN = 0x20000000, LENGTH = 32K } SECTIONS { .text : { *(.text*) } > FLASH .rodata : { *(.rodata*) } > FLASH .data : { *(.data*) } > RAM AT > FLASH .bss : { *(.bss*) *(COMMON) } > RAM }

这是教学用脚本片段,不是任意 MCU 的可用地址;必须替换为当前芯片的官方内存规格和启动实现。

SHELL · SYMBOL REPORT
arm-none-eabi-nm -S --size-sort build/firmware.elf > build/firmware.nm.txt arm-none-eabi-objdump -h -t build/firmware.elf > build/firmware.sections.txt arm-none-eabi-size build/firmware.elf

目标工具链未安装时可先运行主机实验包的模型;不要把命令未执行写成目标地址已经验证。

2.4.3 链接脚本 MEMORY:先声明边界

量与单位:对本章的链接器与 ELF:把符号、段和地址对上来说,符号、重定位、段、节、VMA/LMA、入口点、归档库、弱符号、map 与内存区域组成一条从源代码到地址和行为的证据链:先确认事实,再选择模型或工具,随后记录产物,最后写出覆盖范围。类型、符号、段和地址只有在目标、ABI、脚本、版本和构建选项明确时才有可解释性。

工具条件:在GNU ld、ELF、链接脚本、MEMORY/SECTIONS、静态库和启动入口中,把一段 Flash/RAM 描述转换为可检查的容量预算可能经过多个阶段。文本会被展开,单元会被编译,符号会被重定位,启动代码还可能改变内存内容;所以成功路径和失败路径都要写出来。把“应该能编译”改成带命令、输入和观察方式的句子,读者才有机会复现。

MEMORY 块是容量合同,不是装饰。ORIGIN 和 LENGTH 表达区域边界,区域属性帮助脚本和诊断检查读写执行意图;当 .text、.data 或自定义节超过长度时,链接器可以在构建阶段失败。把溢出留到烧录或启动后才发现,会让故障定位变得昂贵。

表 4 · 链接器与 ELF:把符号、段和地址对上的证据判断表
对象应留下的证据不能直接推出
符号名字、绑定、大小单独地址就是行为
脚本区域、节、入口适用于所有芯片
map预算、空洞、来源只需保留最终 bin

2.4.4 SECTIONS:决定谁放在哪里

自定义节常用于启动表、校验数据、版本信息或不应在启动时清零的保留区。KEEP 可以防止垃圾回收删除看似未引用的输入节,NOLOAD 可以表达运行时存在但不需要装载初值的区域。每个特殊规则都需要说明启动代码、校验工具和目标内存是否真的按同一合同使用。

2.4.5 入口点与启动对象:谁先运行

入口点只是开始执行的地址,不等同于 C 的 main。Cortex-M 复位后通常先从向量表取初始栈和 reset handler,随后由启动代码复制数据、清零 bss、配置时钟或运行系统初始化。链接器只能证明入口和地址关系,不能证明复位后这些动作全部成功。

图 4.1 · 链接器与 ELF:把符号、段和地址对上的判断路径每一格都要有自己的条件。
输入.o、.a、启动
规则MEMORY/SECTIONS
地址符号与重定位
产物ELF/map/bin

从左到右是思考顺序,不是把四类证据合并成一个“通过”。

2.4.6 静态库与链接顺序:需要时才拉入对象

静态库按需提取成员,因此链接顺序可能成为行为。一个库依赖另一个库时,通常需要让被依赖库出现在后面;--start-group 等选项可以解决环,但也会改变审计复杂度。最小复现应保留输入对象和库顺序,错误信息往往比反复加库更有解释力。

2.4.7 弱符号与覆盖:方便不等于透明

弱符号适合提供可覆盖的默认实现,但它会让‘程序链接成功’与‘正确处理事件’分开。若中断名拼写错误或向量表没有指向强覆盖,默认 handler 仍可能让构建通过。交付应检查符号绑定、向量表内容和触发后的目标机现象。

2.4.8 map 文件:地址推导的审计入口

map 文件像一张地址账本:它可以显示每个区域的起止、对象大小、对齐填充、库成员来源和未使用空间。阅读 map 时要先固定目标、脚本和命令,再比较一次变量变化;否则来自不同工具链或不同垃圾回收策略的数字无法直接比较。

2.4.9 本节交付:一份 ELF 地址报告

地址报告的限制必须显式写出。它不自动覆盖电源、复位时序、外设引脚、芯片勘误、烧录器和现场温度;它只证明输入文件在指定工具链和脚本下形成了某种地址映像。把这个范围交给读者,下一章才可以在同一证据链上继续。

CHECKPOINT 2.4把“链接器脚本为什么能决定入口、段位置和内存边界?”写成一份可复查记录

请围绕“链接器与 ELF:把符号、段和地址对上”写出对象、输入或构建目标、状态/机制、输出、一个约束、一个失败路径和对应证据。没有目标板时,运行主机路径并把未执行字段标为未执行;拥有目标机时,也要补齐工具链、链接脚本、板卡、供电、启动和观察条件。不要用一次构建成功或一个 bin 文件覆盖没有做过的验证。

合格记录要让没有现场上下文的读者知道先查什么、先运行什么、看到什么才算通过,以及哪一句结论仍然不能推出。未知项和停止条件不是扣分项,而是交付的一部分。

05 · ABI AND STARTUP

ABI 与启动:C 如何抵达 Cortex-M

从 C 到 Cortex-M 不是把编译器换成 arm-none-eabi-gcc 就结束。调用约定、寄存器保存、栈对齐、启动文件、向量表、时钟和 C runtime 共同定义了程序如何从复位走到 main。本单元把主机可验证的 ABI 线索与目标机必须实测的启动证据分开。

看懂理解 ABI、调用约定和栈的联系。
说清追踪复位向量到 main 的启动链。
开始做区分主机构建证据与目标机启动证据。
官方基线Cortex-M0+ Devices Generic User Guide处理器寄存器、异常、栈与调试语义的 Arm 官方基线。锁定:DUI 0662B · 核对日期:2026-09-05

2.5.1 ABI:函数调用不是编译器私事

ABI 是二进制接口合同,规定调用者和被调用者如何交换参数、返回值、栈帧和被调用者保存寄存器。只要两个目标文件在同一合同下编译,函数名之外的机器级约定才能对得上;混用编译选项或不同 ABI 可能仍然生成文件,却在调用边界破坏数据。

2.5.2 反汇编:从指令回到 C 合同

量与单位:对本章的ABI 与启动:C 如何抵达 Cortex-M来说,ABI、AAPCS、寄存器、栈对齐、复位、向量表、Reset_Handler、CMSIS 和 C runtime组成一条从源代码到地址和行为的证据链:先确认事实,再选择模型或工具,随后记录产物,最后写出覆盖范围。类型、符号、段和地址只有在目标、ABI、脚本、版本和构建选项明确时才有可解释性。

工具条件:在Arm Cortex-M、GNU Arm 工具链、启动文件、向量表与 CMSIS-Core中,为一个短函数标出输入、输出和保存恢复的证据可能经过多个阶段。文本会被展开,单元会被编译,符号会被重定位,启动代码还可能改变内存内容;所以成功路径和失败路径都要写出来。把“应该能编译”改成带命令、输入和观察方式的句子,读者才有机会复现。

反汇编适合回答‘编译器为这个函数安排了什么’,不适合单独回答‘设备一定按这个时序工作’。看到 push、mov、ldr、bl 和 pop 可以帮助定位参数与栈帧,但优化、链接、异常和中断会改变观察。保存反汇编时应带目标三元组、优化等级和源文件版本。

C · RESET CONTRACT
#include <stdint.h> typedef void (*isr_handler_t)(void); extern uint32_t __stack_top; void Reset_Handler(void); void Default_Handler(void); __attribute__((section(".isr_vector"))) const uintptr_t vector_table[] = { (uintptr_t)&__stack_top, (uintptr_t)Reset_Handler, (uintptr_t)Default_Handler, };

向量表的节名、地址和类型必须匹配具体 Cortex-M 启动文件与链接脚本;该片段只展示接口关系。

SHELL · ABI FINGERPRINT
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfloat-abi=soft -dM -E - < NUL > build/target-macros.txt arm-none-eabi-gcc -v 2> build/toolchain-version.txt

Windows、macOS、Linux 的输入重定向写法不同;记录目标、ABI 和版本,比复制一条无法运行的命令更重要。

2.5.3 复位向量:第一条取自哪里

Cortex-M 向量表通常把初始主栈指针和异常处理入口放在固定布局中,但具体地址、重映射和启动模式由芯片与工程决定。链接脚本把向量输入节放到预期区域,启动代码和芯片复位逻辑再共同使用它;任何一层不一致都可能表现为立即异常或无日志。

表 5 · ABI 与启动:C 如何抵达 Cortex-M的证据判断表
对象应留下的证据不能直接推出
主机函数语义与部分 ABI复位和向量正确
ELF入口、符号、地址电源和时钟正常
目标机复位、启动、行为可以跳过静态证据

2.5.4 Reset_Handler:先建立 C 世界

Reset_Handler 的每个动作都应有自己的验收问题。栈指针是否落在有效 RAM?data 的加载值是否从正确的 Flash 区复制?bss 是否清零?系统时钟是否按目标器件初始化?main 是否被调用?把这些问题拆开,启动故障就不再只剩‘板子没反应’。

2.5.5 CMSIS-Core:标准接口与具体芯片之间

CMSIS-Core 提供跨 Cortex-M 内核的接口和头文件组织,但设备寄存器、启动文件、时钟树和中断名称仍来自具体厂商与芯片。引用 CMSIS 资料时要说明版本和层级,不能用一个通用核心文档替代当前 MCU 的参考手册。

图 5.1 · ABI 与启动:C 如何抵达 Cortex-M的判断路径每一格都要有自己的条件。
复位向量表与 MSP
启动Reset_Handler
运行时data/bss/时钟
应用main 与外设

从左到右是思考顺序,不是把四类证据合并成一个“通过”。

2.5.6 浮点、FPU 与 ABI 选项

浮点 ABI 选项必须在编译、汇编、库和链接阶段保持一致。硬浮点对象可能使用不同寄存器约定,软浮点对象则通过库调用完成计算;混合后可能直接链接失败,也可能在边界上产生难以解释的运行问题。构建清单要把 -mfloat-abi、-mfpu 等选项完整留下。

2.5.7 主机可验证什么,目标机必须补什么

主机可以验证函数输入输出、错误返回、部分结构布局和构建规则;它不能证明复位引脚、电源、时钟稳定、向量表映射、调试器连接或实际中断触发。目标机记录必须把静态 ELF 证据和现场现象并列,而不是用前者替代后者。

2.5.8 本节交付:一条从复位到 main 的证据链

启动链交付最好附一张顺序图和一份最小日志。顺序图告诉读者先后关系,日志告诉验收者发生了什么,ELF/map 告诉读者地址来自哪里,芯片手册告诉读者哪些条件适用。四类材料各自回答不同问题,缺一项就不要扩大结论。

CHECKPOINT 2.5把“C 函数调用、复位入口和向量表之间如何接上?”写成一份可复查记录

请围绕“ABI 与启动:C 如何抵达 Cortex-M”写出对象、输入或构建目标、状态/机制、输出、一个约束、一个失败路径和对应证据。没有目标板时,运行主机路径并把未执行字段标为未执行;拥有目标机时,也要补齐工具链、链接脚本、板卡、供电、启动和观察条件。不要用一次构建成功或一个 bin 文件覆盖没有做过的验证。

合格记录要让没有现场上下文的读者知道先查什么、先运行什么、看到什么才算通过,以及哪一句结论仍然不能推出。未知项和停止条件不是扣分项,而是交付的一部分。

06 · BUILD DELIVERY

构建交付:让别人从干净目录复现

第 2 章最后交付的不是一个看似能下载的 bin,而是一条可复查的构建路径:读者知道从哪里开始、使用哪个工具、得到哪些中间产物、如何检查内存预算,以及哪些目标机事实仍然没有验证。

看懂编写可复现的构建清单。
说清用产物、map 和测试形成证据链。
开始做明确主机路径与目标机路径的边界。
官方基线Arm GNU Toolchain编译、链接、ELF/map 和工具输出按课程锁定工具链复现。锁定:15.2.rel1 · 核对日期:2026-09-05

2.6.1 干净目录:先消除私人环境

干净目录的意义是让路径本身成为证据。读者应能从 README 中知道需要什么解释器或工具链、在哪个目录运行、命令会写出哪些文件、成功怎样判断、失败如何保存现场。绝对路径、作者机器上的缓存和没有版本的‘默认工具’都会削弱这条证据链。

2.6.2 构建清单:每一步都有输入和输出

产物分工能减少误读。ELF 保存符号和调试信息,map 展示地址账本,bin/hex 适合烧录或传输,日志记录工具实际做了什么,校验值帮助确认文件没有被替换。只提供 bin 会让读者无法定位段溢出、入口地址、库来源或版本差异。

PYTHON · BUILD MANIFEST
from pathlib import Path import hashlib root = Path("build") for path in sorted(root.glob("*")): if path.is_file(): digest = hashlib.sha256(path.read_bytes()).hexdigest() print(path.name, path.stat().st_size, digest)

脚本只读取构建目录并输出文件大小与 SHA-256;它不会替代目标机烧录、启动或行为验收。

MAKE · EXPLICIT TARGETS
CC ?= cc CFLAGS ?= -std=c17 -Wall -Wextra -g build/main.o: src/main.c include/main.h $(CC) $(CFLAGS) -Iinclude -c $< -o $@ build/main: build/main.o $(CC) $^ -o $@

Makefile 是构建图示例;目标工具链、路径和链接脚本要按读者平台与具体 MCU 补齐。

2.6.3 产物清单:不只保留 bin

工具版本不是附录信息。编译器、汇编器和链接器的默认选项、库实现、诊断文字和节排序可能改变;芯片头文件和链接脚本也会随 SDK 更新。记录版本不要求锁死所有未来变化,但要求下一位读者知道这次结果在什么条件下成立,并能判断更新是否需要重新验证。

表 6 · 构建交付:让别人从干净目录复现的证据判断表
对象应留下的证据不能直接推出
输入源码、头文件、版本作者目录缓存
过程命令、工具、日志IDE 绿色提示
产物ELF、map、校验只有 bin 足够复查

2.6.4 版本与来源:工具也有身份

量与单位:对本章的构建交付:让别人从干净目录复现来说,构建清单、锁定版本、产物清单、校验、map 差异、CI、恢复步骤与证据边界组成一条从源代码到地址和行为的证据链:先确认事实,再选择模型或工具,随后记录产物,最后写出覆盖范围。类型、符号、段和地址只有在目标、ABI、脚本、版本和构建选项明确时才有可解释性。

工具条件:在干净目录、构建清单、ELF/map/bin、CI、版本和实验包交接中,比较一次版本升级带来的 map 或诊断变化可能经过多个阶段。文本会被展开,单元会被编译,符号会被重定位,启动代码还可能改变内存内容;所以成功路径和失败路径都要写出来。把“应该能编译”改成带命令、输入和观察方式的句子,读者才有机会复现。

单变量回归报告要先写预测。比如只把一个 const 表改成更长数组,应预计 .rodata、Flash 映像和可能的对齐如何变化;运行后再用 size/map 验证差异。若结果不同,列出工具优化、垃圾回收、压缩或测量脚本的可能解释,而不是修改叙述去迎合结果。

2.6.5 单变量回归:改一个对象看一条差异

CI 的绿色状态只覆盖被配置的步骤。若流水线没有开启警告、没有保存 ELF/map、没有运行边界测试,绿色并不能推出这些事实。把本地与 CI 共享的入口脚本、版本打印和产物上传规则写清楚,才能让‘通过’具有可复查含义。

图 6.1 · 构建交付:让别人从干净目录复现的判断路径每一格都要有自己的条件。
输入源码与版本
过程命令与工具
产物ELF/map/bin
交付测试与边界

从左到右是思考顺序,不是把四类证据合并成一个“通过”。

2.6.6 CI 与本地:同一条合同两种入口

失败恢复需要有边界。链接溢出时可以回到上一次已知正常提交、减少一个实验对象或只运行主机模型;烧录失败时先断电、保存日志、核对板型和供电,不要连续试错。恢复动作本身也应记录版本与结果,防止下一位读者重复破坏现场。

2.6.7 失败与恢复:保留最后已知正常

主机实验包的价值是可迁移字段,而不是伪造目标板。它可以验证依赖图、符号合同、尺寸预算和错误输入;它不能测量真实 Flash/RAM、复位、电源、时钟或 GPIO。README 应把‘已验证’和‘未执行’分开,读者才能决定是否继续接目标机。

2.6.8 主机实验包:没有目标板也能交付

下一章会把对象合同和构建产物连接到 GPIO。届时变量可能变成寄存器,段可能变成启动区域,构建命令可能包含设备头文件;但输入、输出、证据、失败和限制这些字段仍然沿用。本章交付的不是一次性的命令,而是一种能被复制的工作语言。

2.6.9 本章结业:交给下一章的接口

结业包应让没有现场上下文的人完成三件事:复现主机构建或模型测试,解释一个产物差异,准确说出尚未证明的目标机事实。若读者只能看到一个‘成功下载’截图,却没有命令、版本、ELF/map 和限制,交付仍然不完整。

CHECKPOINT 2.6把“怎样把源代码、工具链、产物和 map 文件交给下一位读者?”写成一份可复查记录

请围绕“构建交付:让别人从干净目录复现”写出对象、输入或构建目标、状态/机制、输出、一个约束、一个失败路径和对应证据。没有目标板时,运行主机路径并把未执行字段标为未执行;拥有目标机时,也要补齐工具链、链接脚本、板卡、供电、启动和观察条件。不要用一次构建成功或一个 bin 文件覆盖没有做过的验证。

合格记录要让没有现场上下文的读者知道先查什么、先运行什么、看到什么才算通过,以及哪一句结论仍然不能推出。未知项和停止条件不是扣分项,而是交付的一部分。

资料卡(访问日期:2026-09-03):GCC 文档支撑阶段化驱动与链接选项;GNU ld 文档支撑 MEMORY、SECTIONS 和入口规则;GNU make 文档支撑依赖图;Arm CMSIS-Core 文档与官方 GitHub 仓库支撑 Cortex-M 核心接口和版本核对。本站的“主机/目标机边界”是教学解释,具体芯片、工具链和链接脚本仍须按读者当前版本核对。