目录
- 0-环境准备(以linux/WSL为例)
- 一、核心代码实现与分析
-
- [💎1. 启动汇编:startup.s](#💎1. 启动汇编:startup.s)
- [💎2. 链接脚本:linker.ld](#💎2. 链接脚本:linker.ld)
- [💎3. 主程序:test.c](#💎3. 主程序:test.c)
- [💎4. 自动化构建工具:Makefile](#💎4. 自动化构建工具:Makefile)
- [5. 排错记录](#5. 排错记录)
- 二、工程细节:交叉编译与二进制读取
- 三、总结
- 具体步骤:
- 编写一个简单的 C 程序,通过向模拟器的 UART 寄存器地址写数据来输出 "Hello World"。
- 自己写 Linker Script (.ld 文件)。把 FLASH 和 RAM 地址映射到 QEMU 模拟的具体芯片手册地址上。
- 使用 arm-none-eabi-gcc 交叉编译,并在 QEMU 中启动。
- 对AI后端的意义: 真正理解程序的"第一行代码"是如何执行的,理解内存布局(Section)如何映射到物理地址
0-环境准备(以linux/WSL为例)
安装交叉编译器和 QEMU 模拟器
bash
sudo apt-get update
sudo apt-get install gcc-arm-none-eabi qemu-system-arm
一、核心代码实现与分析
实验涉及三个关键文件,分别对应硬件启动、内存布局和业务逻辑。
💎1. 启动汇编:startup.s
CPU上电后并不支持C语言环境(如栈空间、全局变量初始化)。我们需要汇编代码进行初步引导:
bash
.section .vector_table, "a"
.global _start
_start:
ldr sp, =stack_top @ 设置栈指针,为运行 C 代码准备环境
bl c_entry @ 跳转到 C 语言入口函数
b . @ 防止程序跑飞,进入死循环
-
栈初始化:C 语言中的局部变量存储、函数返回地址压栈、参数传递都依赖于栈(Stack)。在裸机环境下,硬件不会自动分配栈空间。我们通过汇编指令手动将栈指针(SP)指向一片合法的内存区域。
-
这行 ldr sp 就是软件世界与硬件物理空间的第一次握手。没有它,后续任何 C 函数调用都会导致系统崩溃。
💎2. 链接脚本:linker.ld
控制程序在内存中如何排布:
bash
ENTRY(_start)
SECTIONS
{
/* QEMU 加载 ARM 程序的默认起始地址 */
. = 0x10000;
/* 代码段 */
.text : {
*(.vector_table) /* 确保启动汇编在最前面 */
*(.text)
}
/* 已初始化数据段 */
.data : { *(.data) }
/* 未初始化数据段 */
.bss : { *(.bss) }
/* 栈空间分配 */
. = ALIGN(8); /* 8 字节对齐 */
. = . + 0x1000; /* 分配 4KB 栈空间 */
stack_top = .; /* 符号定义,供 startup.s 使用 */
}
- 符号地址化:链接脚本决定了编译后的每一行指令、每一个全局变量在内存中的 绝对地址。
- 高性能算子优化中,我们常谈论 Cache Line 对齐(64字节)。通过控制链接脚本,我们可以确保模型权重(Weights)分布在特定的内存边界上,从而避免 CPU 跨行读取导致的性能损耗。本实验中,我们将程序基地址定在 0x10000,这是 QEMU 模拟器硬件规范要求的入口点。
💎3. 主程序:test.c
我们跳过操作系统驱动,直接操作硬件寄存器映射的内存地址。
c
/* test.c */
/* 定义 UART0 寄存器物理地址 (基于 ARM VersatilePB) */
volatile unsigned int * const UART0DR = (unsigned int *)0x101f1000; // 数据寄存器
volatile unsigned int * const UART0FR = (unsigned int *)0x101f1018; // 状态标志寄存器
/**
* 往串口发送一个字符
*/
void uart_putc(char c) {
/* 等待发送 FIFO 缓冲区不满 (TXFF 标志位) */
/* 在 QEMU 中这一步通常很快,但在真实硬件上至关重要 */
while (*UART0FR & (1 << 5));
/* 将字符写入数据寄存器 */
*UART0DR = (unsigned int)c;
}
/**
* 往串口发送字符串
*/
void uart_puts(const char *s) {
while (*s) {
/* 转换换行符,增加回车,保证在终端显示正确 */
if (*s == '\n') uart_putc('\r');
uart_putc(*s++);
}
}
/**
* C 语言入口函数 (由 startup.s 调用)
*/
void c_entry() {
uart_puts("------------------------------\n");
uart_puts("Hello, Embedded AI World!\n");
uart_puts("Bare-metal on QEMU is running.\n");
uart_puts("------------------------------\n");
/* 进入死循环,防止程序结束 */
while (1);
}
}
- volatile 关键字:编译器(GCC)的优化逻辑是基于"内存读写平衡"的。如果你连续向同一个地址写入数据而不读取,编译器可能会优化掉中间过程。使用 volatile 关键字是强制告诉编译器:"这个地址连接着外部硬件,每一次写入都有副作用,绝对禁止优化。
- 寄存器映射 I/O:在 ARM 架构中,外设(如串口 UART)被映射到 CPU 的地址空间。硬件地址 0x101f1000 并不是普通的 RAM,它连接到了 UART 控制器。往这个地址写数据,本质上是触发了芯片内部的电路逻辑。
💎4. 自动化构建工具:Makefile
作用:一键编译、一键运行、一键清理。
bash
# Makefile
# 交叉编译器前缀
CC = arm-none-eabi-gcc
AS = arm-none-eabi-as
LD = arm-none-eabi-ld
OBJCOPY = arm-none-eabi-objcopy
# 参数设置
CPU = arm926ej-s
CFLAGS = -mcpu=$(CPU) -g -Wall
LDFLAGS = -T linker.ld
# 目标文件
all: test.bin
startup.o: startup.s
$(AS) -mcpu=$(CPU) -g startup.s -o startup.o
test.o: test.c
$(CC) -c $(CFLAGS) test.c -o test.o
test.elf: startup.o test.o linker.ld
$(LD) $(LDFLAGS) startup.o test.o -o test.elf
test.bin: test.elf
$(OBJCOPY) -O binary test.elf test.bin
# 运行命令
run: test.elf
qemu-system-arm -M versatilepb -m 128M -nographic -kernel test.elf
# 清理命令
clean:
rm -f *.o *.elf *.bin
.PHONY: all run clean
- 编译:打开终端,输入 make。它会自动生成 test.elf 和 test.bin。
- 运行:输入 make run。
看到输出后,退出请按 Ctrl + A 接着按 X。 - 清理:输入 make clean 删除编译生成的临时文件。

5. 排错记录
在实验中,我遇到了 QEMU 启动后卡住的问题。通过 arm-none-eabi-objdump -d 查看反汇编结果,发现是由于链接顺序不当导致 _start 未排在文件首位,导致 CPU 执行了错误的指令。
此外,-nographic 参数会自动占用 stdio,在启动命令中需注意串口重定向的冲突。
二、工程细节:交叉编译与二进制读取
在 X86 架构的电脑上开发 ARM 程序,涉及复杂的工具链链条:
- 交叉编译(Cross-compilation):使用 arm-none-eabi-gcc,将 C 源码翻译成 ARM 指令集。
- ELF 与 Binary 的区别:
- ELF 文件:包含符号表、调试信息和段元数据(用于 Linux 加载)。
- BIN 文件:剔除了所有元数据,只剩下纯粹的代码机器码(用于裸机直接烧录)。
- QEMU 模拟器:通过 -kernel 参数加载 ELF,它会自动解析 ENTRY 符号并跳转到 _start。
三、总结
- Zero-Copy(零拷贝) 技术(如 Kafka 或高性能推理框架)
理解了 MMIO,就能理解为什么 DMA 搬运数据不需要 CPU 参与。本质上就是:我不移动数据,我只把存放数据的"物理地址"告诉网卡或显卡,让它们直接去读。" - 资源开销的颗粒度
当我们在高级语言里申请一个 buffer 时,通过本实验可知,底层经历了符号重定位、栈增长和 Cache 映射。