第五部分详细计划
- 编译四阶段与交叉编译
- 预处理、编译、汇编、链接各阶段动作
- 交叉编译概念
extern "C"与 C/C++ 混合编程- 符号名修饰(name mangling)
- 如何安全地在 C++ 中调用 C 函数
- 程序段分布(text/data/bss/stack/heap)与启动代码
- 各段含义、存储位置(Flash vs RAM)
- 启动文件(startup)职责:拷贝 data、清零 bss、初始化栈
- 中断服务函数(ISR)的特殊要求
- 无参无返回值、尽量短小
__attribute__((interrupt))的作用- 中断嵌套与重入
volatile与原子性问题再次强调
- 原子操作与临界区保护
- 关中断/开中断(
__disable_irq/__enable_irq) - 互斥量/自旋锁(RTOS)
- LDREX/STREX 指令与原子读-改-写
- 关中断/开中断(
- 大小端、字节序转换与数据序列化
- 网络字节序与主机字节序
htonl/ntohl的实现原理- 结构体跨平台通信陷阱
- 内存屏障与乱序执行初步
- 编译器屏障
asm volatile("" ::: "memory") - 硬件内存屏障指令(DMB/DSB/ISB 简介)
- 编译器屏障
5.1 编译四阶段与交叉编译
概念讲解
C 代码到可执行文件经历四个阶段:
- 预处理(Preprocessing) :处理
#include、#define、条件编译,生成.i文件。 - 编译(Compilation) :将预处理后的代码翻译为汇编代码(
.s)。 - 汇编(Assembly) :将汇编代码转为目标文件(
.o),包含机器码和符号表。 - 链接(Linking) :合并所有
.o文件和库,重定位符号地址,生成最终的.elf/.bin/.hex。
交叉编译:在 PC(x86)上编译生成目标平台(如 ARM)的可执行代码,嵌入式开发必然涉及。
经典例题 1
题目: 链接器如何得知 main 函数的入口地址?-nostartfiles 选项的作用?
解析:
- 标准程序入口通常是
_start(由 crt0.o 提供),它会初始化 C 运行环境(栈、data/bss)后调用main。链接脚本(linker script)定义入口点。 -nostartfiles告诉链接器不使用标准启动文件,常用于裸机编程,需自行提供Reset_Handler作为入口。
5.2 extern "C" 与 C/C++ 混合编程
概念讲解
C++ 支持函数重载,编译器会将函数名修饰为包含参数类型信息的符号(如 _Z3fooii),而 C 语言符号直接是 foo。当 C++ 调用 C 库时,若不加 extern "C",链接器会找 C++ 修饰名而失败。
正确用法:
c
#ifdef __cplusplus
extern "C" {
#endif
void my_c_function(int);
#ifdef __cplusplus
}
#endif
嵌入式中,底层驱动常用 C 编写,上层应用有时用 C++,这个知识点必须掌握。
经典例题 2
题目: 写出 C 语言头文件的标准开头和结尾,保证可被 C++ 安全包含。
答案:
c
#ifndef MY_HEADER_H
#define MY_HEADER_H
#ifdef __cplusplus
extern "C" {
#endif
/* C 函数声明 */
#ifdef __cplusplus
}
#endif
#endif
5.3 程序段分布与启动代码
概念讲解
嵌入式程序的典型内存布局(裸机/简单RTOS):
.text:代码段,存放在 Flash/ROM,只读。.rodata:只读数据(const全局变量、字符串常量),通常也在 Flash。.data:已初始化的全局/静态变量,初始值存于 Flash,启动时拷贝到 RAM。.bss:未初始化或初始化为0的全局/静态变量,无需存初始值,启动时在 RAM 中清零。- 栈(stack):局部变量、函数调用上下文,向下(或向上)增长。
- 堆(heap):动态分配内存区域。
启动代码 (startup.s 或启动文件)负责:
- 设置栈指针(SP)。
- 将
.data段从 Flash 复制到 RAM。 - 清零
.bss段。 - 调用
main(或 RTOS 启动调度器)。
经典例题 3
题目: 一个全局变量 int a = 5; 和 static int b; 分别在什么段?b 的初始值是什么?
解析:
a的初始值5存在.data的 Flash 镜像中,运行时在 RAM 的.data段。b在.bss段,启动代码会将其清零,故初始值为 0(标准规定静态变量默认初始化为0)。
5.4 中断服务函数(ISR)的特殊要求
概念讲解
ISR 与普通函数最大不同:
- 无调用者,硬件直接跳转。
- 必须快速返回,避免阻塞其他中断。
- 禁止使用会导致阻塞或不确定时间的操作 (如
printf、malloc、浮点运算)。 - 平台相关 :ARM Cortex-M 中,ISR 是普通 C 函数,但需用特定属性或放置在中断向量表;某些 MCU 需
__attribute__((interrupt))。
嵌入式铁律:ISR 中仅做"记录事件、清除标志、发送信号量"等轻量工作,主要处理交给任务或主循环。
经典例题 4
题目: 下面的 ISR 有什么问题?
c
void UART0_IRQHandler(void) {
uint8_t data = UART0->DR;
printf("Received: %c\n", data);
}
解析:
printf通常是非重入函数,且可能使用全局缓冲区,若主程序也在执行printf被中断,会造成数据混乱或死锁。- 不可在 ISR 中使用标准 I/O 或可能阻塞的函数。应该将
data存入循环缓冲区并置标志,由主程序处理。
5.5 原子操作与临界区保护
概念讲解
嵌入式系统,特别是与硬件寄存器交互或与中断共享数据时,常需要保证一段代码不被中断或其它任务打断。
常用方法:
- 关中断 :
__disable_irq()/__enable_irq()。简单但延长中断延迟,不宜长时间禁用。 - 原子指令 :ARM Cortex-M 提供
LDREX/STREX,可实现无锁原子操作;C11 的_Atomic在支持时可用。 - 互斥量/信号量 :用于任务间同步,会有上下文切换开销,不能用于 ISR(ISR 中只能用
FromISR版本且不能阻塞)。
经典例题 5
题目: 在 RTOS 中,有一个全局变量 int counter; 被多个任务执行 counter++;,需要保护吗?
解析:
- 需要!
counter++在汇编层面通常是三条指令(加载、加一、存储),可能被任务切换打断,导致丢失更新。 - 解决方法:使用临界区、原子操作,或使用 RTOS 提供的信号量/互斥量保护。
5.6 大小端、字节序转换与数据序列化
概念讲解
- 小端(Little-Endian):低字节在低地址(ARM 大多数模式、x86)。
- 大端(Big-Endian):低字节在高地址(网络字节序、某些通信协议)。
在网络传输或多芯片通信中,必须统一字节序。使用以下函数转换32位数据:
c
#define htonl(x) ((((uint32_t)(x) & 0x000000FF) << 24) | \
(((uint32_t)(x) & 0x0000FF00) << 8) | \
(((uint32_t)(x) & 0x00FF0000) >> 8) | \
(((uint32_t)(x) & 0xFF000000) >> 24))
结构体直接发送是危险的行为,因为有填充和对齐差异。正确做法是逐字段序列化。
经典例题 6
题目: 实现 uint16_t swap16(uint16_t val) 函数,将高8位与低8位交换。
答案:
c
uint16_t swap16(uint16_t val) {
return (uint16_t)((val << 8) | (val >> 8));
}
5.7 内存屏障与乱序执行初步
概念讲解
现代处理器和编译器都可能为了性能重排指令。在访问硬件寄存器或共享内存时,必须使用屏障确保顺序:
- 编译器屏障 :
asm volatile("" ::: "memory");告诉编译器不要跨越此点重排内存访问,并假设所有内存已改变。 - 硬件屏障 :ARM 的
DMB(数据内存屏障)保证在屏障之前的所有内存访问完成后才执行之后的访问;DSB则等待所有缓存和写缓冲区操作完成。
嵌入式中典型应用:访问 DMA 控制器时,必须先配置好描述符,然后通过屏障确保描述符写入完毕,再启动 DMA。
经典例题 7
题目: 以下代码可能在多核或乱序 CPU 上出错,为什么?如何修复?
c
// core0 写数据,然后写 flag
data = 0x1234;
flag = 1;
// core1 循环等待 flag,然后读 data
while (flag != 1);
printf("%x\n", data);
解析:
- core0 的写操作可能因为编译器或硬件优化重排,
flag = 1先于data = 0x1234生效。 - core1 在
flag为1时读到旧data值。 - 修复 :在
flag = 1;前加内存屏障(如 ARM 的DMB),保证 data 写入后 flag 才可见。或在变量声明时使用volatile和屏障组合。