1. 项目概述:这不是教你怎么写 printf,而是搞懂它为什么能跑起来
“单片机底层系列:C 库运行时——从 libspace 到多任务与中断安全”,光看标题,老手一眼就明白:这讲的不是功能怎么用,而是C语言在裸机上“活下来”的全过程。我带过不少刚从STM32标准库跳到HAL、又一头扎进FreeRTOS的开发者,他们能调通串口、点亮LED、甚至跑起任务调度器,但只要一加个malloc就死机,一在中断里调sprintf就飞掉,一换芯片型号就发现__libc_init_array报错——问题全出在“C库运行时”这个被默认忽略的黑箱里。
这个标题里的三个关键词,就是整条技术链的锚点:libspace是内存布局的起点,是链接脚本里那几行没人细看却决定一切的.data复制和.bss清零;多任务不是简单地创建几个 task,而是 runtime 必须支持重入、栈隔离、全局变量私有化;中断安全更不是加个__disable_irq()就万事大吉,而是errno怎么存、malloc的 heap 锁怎么设、FILE*指针在 ISR 里为何不能碰。它解决的是一个根本问题:当编译器把int main(void)编译成一段机器码后,谁来给它铺好路?谁来管它的家(栈)、粮仓(heap)、户口本(.data/.bss)、以及它在多线程世界里会不会被抢东西?
适合谁读?如果你正在做以下任何一件事,这篇就是为你写的:
- 用 Keil/ARM GCC 写裸机程序,但
printf输出乱码或卡死; - 移植 CMSIS 或自定义启动文件,发现全局变量初始值不对;
- 在 FreeRTOS 中使用
newlib-nano,结果fopen返回 NULL 或free崩溃; - 调试时发现
main函数还没执行,PC 就跳到了0x00000000; - 看过《ARM Cortex-M 权威指南》第7章,但对
__main和__libc_init_array的调用顺序始终模糊。
它不讲 C 语法,不教 RTOS API,只聚焦一个被无数教程跳过的环节:C语言在没有操作系统内核兜底的情况下,如何靠自己的一套基础设施,在单片机上完成从复位到main的完整成人礼。接下来,我们就一层层剥开这个 runtime 的皮,从最底层的内存空间定义开始,一直看到它如何在多任务夹缝中守住最后一道安全线。
2. 整体设计思路:为什么必须亲手“造轮子”,而不是直接抄 startup.s?
2.1 从“默认能跑”到“崩溃是常态”的认知反转
很多初学者第一次接触单片机,用 Keil 新建工程,点编译,下载,main里while(1) { GPIO_Toggle(); }就亮了。于是形成一个根深蒂固的错觉:“单片机上跑 C 代码,本来就应该这么简单”。这个错觉,恰恰是后续所有 runtime 问题的源头。真相是:你之所以能“默认跑起来”,是因为 Keil/STM32CubeIDE 已经悄悄替你完成了至少 7 项关键初始化工作,而其中任意一项配置错误或缺失,在裸机环境下都会导致不可预测的行为。
我做过一个对照实验:在某款 Cortex-M4 芯片上,用汇编手写一个极简startup.s,只保留Reset_Handler,里面仅做三件事:设置 SP、跳转main、无限循环。编译烧录后,main函数里定义的int a = 5;,实测a的值是随机数(比如0xCAFEBABE);而static int b;的值也不是 0,而是上次 RAM 掉电残留。这就是.data没复制、.bss没清零的典型表现。更隐蔽的问题是:printf依赖的_sbrk系统调用没实现,malloc直接返回NULL,但程序不会报错,只是后续访问空指针时才崩——这种延迟崩溃,调试成本极高。
所以整个 runtime 设计的第一原则,就是主动放弃“默认行为”,把每一处隐式依赖都显式化、可验证、可调试。这意味着我们必须亲手处理:
- 内存空间划分:
libspace不是一个函数,而是一组符号定义,它告诉链接器:.data放哪、.bss放哪、heap 从哪开始、stack 顶在哪; - 启动流程控制:
__main不是 C 标准函数,而是 ARM C 库的入口胶水,它内部调用__scatterload(复制.data)、__rt_bss_zero(清零.bss)、__libc_init_array(执行.init_array段函数); - 系统调用桩(Syscalls):
_write、_read、_sbrk、_close等,这些是 newlib/nano-lib 与硬件交互的唯一接口,不实现它们,printf就是空中楼阁; - 多任务适配层:在 RTOS 下,
errno不能是全局变量,必须 per-task 存储;malloc的 heap 管理器必须支持互斥访问;FILE*结构体中的缓冲区指针必须线程安全。
提示:很多开发者试图绕过 runtime,直接用
__attribute__((naked))写main,以为这样更“底层”。这是危险的捷径。naked函数意味着你放弃了整个 C 运行时环境——没有栈帧、没有参数传递约定、没有返回值处理。你写的不是 C 代码,而是汇编伪码。真正的底层,是理解并掌控 runtime,而不是抛弃它。
2.2 libspace:内存布局的“宪法”,不是可选项
libspace这个词,在 ARM 官方文档里几乎不提,它其实是 ARM C 库(ARMCC/ARMCLANG)内部使用的术语,指代一组用于定义 C 库所需内存区域的符号。在 GNU Arm Embedded Toolchain(即大家常用的 gcc-arm-none-eabi)中,它对应的是链接脚本(.ld文件)中对__initial_sp、__stack_limit、__heap_base、__heap_limit等符号的定义。它之所以关键,是因为所有后续的初始化动作,都依赖于这些符号所划定的空间边界是否准确。
举个最典型的例子:.data段。C 语言规定,已初始化的全局变量(如int flag = 1;)必须在程序启动时拥有正确的初始值。但 Flash 通常只读,RAM 才可写。所以编译器会把.data的初始值存放在 Flash 的某个区域(.dataROM copy),而运行时,必须有一段代码把它复制到 RAM 中对应的.dataRAM 区域。这个复制过程,就依赖__data_start__、__data_end__、__data_load_start__这三个符号。如果链接脚本里__data_load_start__指向了错误的 Flash 地址,或者__data_start__和__data_end__的范围算错了,复制就会越界——轻则覆盖其他变量,重则破坏中断向量表。
我在某次移植 STM32H7 到 IAR 环境时就踩过这个坑。IAR 默认的icf链接脚本把.data放在 DTCM RAM(高速 RAM),但.data的 ROM copy 却被链接器放到了 QSPI Flash 的末尾,而 QSPI 初始化代码在main之后才执行。结果__scatterload尝试从 QSPI 读取.data初始值时,总线直接返回 0xFFFFFFFF,整个.data区域被填满 0xFF,所有初始化变量全失效。解决方法不是改代码,而是修改icf文件,强制将.dataROM copy 分配到内部 Flash 的.text段之后,并确保地址连续。
因此,libspace的设计核心,就是精确、可验证、可移植的内存映射。它不是写死的常量,而是一套符号体系,其值由芯片数据手册(Memory Map)、启动加载方式(XIP/QSPI/SDRAM)、以及你的应用需求(是否需要双 bank OTA、是否启用 cache)共同决定。一个合格的libspace定义,必须包含:
__initial_sp:复位后 MSP 的初始值,即主栈顶地址,必须是 RAM 最高地址 + 4(ARM 栈向下增长);__stack_size__:主栈大小,不能小于main及其调用链的最大深度,建议裸机 ≥ 1KB,RTOS 下每个 task stack 单独管理;__heap_base/__heap_limit:heap 起始与结束地址,必须位于未被.data/.bss/.stack占用的 RAM 区域,且__heap_limit>__heap_base;__data_*和__bss_*符号组:严格对应.data和.bss段的 ROM/RAM 地址与长度。
注意:
__heap_base和__heap_limit的设定,直接决定了malloc的可用空间上限。很多开发者抱怨 “malloc(1024)返回 NULL”,第一反应是 heap 太小,但更常见的情况是:__heap_base被错误地设在了.bss之后,而.bss本身因为未清零,其末尾地址是随机的,导致__heap_base实际指向了非法地址。务必在链接后用arm-none-eabi-objdump -h your.elf检查各段的实际地址和大小,再反推符号值是否合理。
2.3 多任务与中断安全:runtime 的“社会契约”
当单片机从裸机走向 RTOS,C 库 runtime 的角色就从“服务单个进程”升级为“协调多个并发实体”。这时,它不再是一个静态的初始化框架,而是一套动态的资源仲裁机制。其核心挑战在于:C 标准库的绝大多数函数,天生就不是为并发设计的。
以errno为例。在 Linux 下,errno是一个宏,展开为(*__errno_location()),背后是一个 per-thread 的 TLS(Thread Local Storage)变量。但在裸机或早期 RTOS 移植中,开发者常把它定义为一个简单的int errno;全局变量。后果是什么?Task A 调用open()失败,errno=2(ENOENT);Task B 同时调用read()失败,errno=5(EIO);由于没有保护,两个任务会互相覆盖errno的值。当 Task A 后续检查errno时,得到的可能是5,从而做出完全错误的判断。
malloc是另一个重灾区。malloc内部维护一个 heap 管理结构(如malloc_state),记录空闲块链表。如果 Task A 正在malloc分配内存,遍历链表到一半,被 Task B 抢占,Task B 也调用malloc并修改了同一个链表头,那么当 Task A 恢复执行时,它继续遍历的将是一个已被破坏的链表,大概率导致malloc返回非法地址,或free时崩溃。
中断(ISR)则把这个矛盾推向极致。ISR 的优先级通常高于所有任务,它可以随时打断任何 C 函数的执行。想象一下:printf正在往 UART 发送缓冲区写入数据,此时一个高优先级定时器 ISR 触发,它也调用了printf(比如为了打 debug log)。两个printf共享同一个_impure_ptr(newlib 中的全局 impure data 指针),共享同一个stdout的FILE结构体,其内部缓冲区、写位置指针、锁状态全部被 ISR 和任务同时修改——结果必然是缓冲区溢出、指针错乱、UART 发送乱码。
因此,“多任务与中断安全”的 runtime 设计,本质是建立一套最小化、可裁剪、可配置的并发保护契约。它不追求像 Linux 那样完备的 POSIX 线程语义,而是聚焦最关键的几个点:
errno的 per-task 存储:在 RTOS 的 TCB(Task Control Block)中预留一个字段,存放该任务专属的struct _reent实例地址,并在任务切换时,通过__set_errno_location()更新全局_impure_ptr;- heap 的互斥访问:为
malloc/free加一把 RTOS 提供的 mutex(如 FreeRTOS 的xSemaphoreTake(xHeapMutex, portMAX_DELAY)),确保同一时刻只有一个任务能操作 heap; - stdio 的 ISR 隔离:禁止在 ISR 中直接调用
printf等 stdio 函数。必须采用“中断上下文只做最快响应,把耗时工作交给任务”的原则,例如 ISR 中只置位一个标志位或发送一个消息到队列,由高优先级任务去执行实际的printf; _sbrk的原子性:_sbrk是malloc的底层内存分配器,它通过移动heap_brk指针来扩展 heap。这个指针移动操作必须是原子的,否则在多任务下会被打断。通常用__atomic_fetch_add或关中断方式实现。
这套契约的落地,不是靠魔法,而是靠对 RTOS 内核机制的深度理解。比如在 FreeRTOS 中,vTaskSwitchContext()是任务切换的钩子,我们就在其中插入__set_errno_location()的调用;xPortSysTickHandler()是 SysTick 中断服务程序,我们确保它内部绝不调用任何可能触发 heap 分配的函数。这才是“安全”的真正含义:不是加个锁就完事,而是让整个 runtime 的行为,与 RTOS 的调度模型严丝合缝。
3. 核心细节解析:从链接脚本到 syscalls,每一步都是硬骨头
3.1 链接脚本(.ld):runtime 的“施工蓝图”
链接脚本是整个 C 库 runtime 的基石,它不像 C 代码那样可以单步调试,但一旦出错,后果就是整个程序无法启动或行为诡异。一个典型的STM32F407VG_FLASH.ld文件,其核心部分如下(已精简,仅保留与 runtime 直接相关的段):
/* 定义内存区域 */ MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } /* 定义入口点 */ ENTRY(Reset_Handler) SECTIONS { /* .text 段:代码和只读数据,放在 Flash */ .text : { . = ALIGN(4); _stext = .; *(.text .text.*) /* 所有 .text 段 */ *(.rodata .rodata.*) /* 只读数据 */ *(.ARM.extab .ARM.exidx) . = ALIGN(4); _etext = .; } > FLASH /* .data 段:已初始化的全局/静态变量,ROM copy 放 Flash,RAM copy 放 RAM */ .data : AT (_etext) /* AT 指定 ROM copy 地址 */ { . = ALIGN(4); _sdata = .; /* RAM copy 起始地址 */ *(.data .data.*) . = ALIGN(4); _edata = .; /* RAM copy 结束地址 */ } > RAM /* .bss 段:未初始化的全局/静态变量,只放 RAM */ .bss : { . = ALIGN(4); _sbss = .; *(.bss .bss.*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM /* 堆(heap):动态内存分配区域 */ .heap (COPY): { . = ALIGN(8); __heap_base = .; . = . + DEFINED(__HEAP_SIZE) ? __HEAP_SIZE : 0x2000; __heap_limit = .; } > RAM /* 栈(stack):主栈,用于复位后及中断 */ .stack (COPY): { . = ALIGN(8); __stack_start = .; . = . + DEFINED(__STACK_SIZE) ? __STACK_SIZE : 0x400; __stack_end = .; } > RAM /* 定义一些关键符号,供 startup.s 和 C 库使用 */ __initial_sp = __stack_end; __stack_limit = __stack_start; }这段脚本里,每一个.(当前地址计数器)的移动、每一个ALIGN(4)的对齐、每一个AT (...)的指定,都必须与芯片手册的内存映射图(Memory Map)完全一致。我曾在一个项目中,因为误将.data的AT (_etext)写成了AT (_stext),导致.dataROM copy 被链接到了.text段的开头,覆盖了复位向量表,结果芯片复位后直接跳到0x00000000,连调试器都连不上。
关键符号的定义逻辑如下:
__initial_sp:必须等于__stack_end,因为 ARM Cortex-M 的复位向量是栈顶地址,CPU 上电后会自动从这个地址加载 MSP。__stack_limit:必须等于__stack_start,表示栈的底部(最低地址),用于栈溢出检测(如果启用了)。__heap_base/__heap_limit:__heap_base是 heap 的起始地址,__heap_limit是 heap 的结束地址。注意__heap_limit必须大于__heap_base,且两者之间不能与其他段(如.bss)重叠。DEFINED(__HEAP_SIZE)是一个预定义宏,可以在编译时通过-D__HEAP_SIZE=0x1000动态指定 heap 大小,非常灵活。
实操心得:永远不要手动计算地址!使用
arm-none-eabi-objdump -h your.elf查看各段的实际地址和大小,再用arm-none-eabi-nm your.elf | grep "__heap"检查符号值。如果__heap_base的值小于_ebss(.bss结束地址),说明 heap 和.bss重叠了,必须调整__HEAP_SIZE或.bss的位置。
3.2 启动文件(startup.s):runtime 的“点火开关”
启动文件是 CPU 复位后执行的第一段代码,它负责搭建 C 语言运行的最基础环境。一个精简但完整的startup_stm32f407vg.s关键部分如下(ARM GAS 语法):
.section ".isr_vector","a",%progbits .global g_pfnVectors g_pfnVectors: .word __initial_sp /* Top of Stack */ .word Reset_Handler /* Reset Handler */ .word NMI_Handler /* NMI Handler */ /* ... 其他中断向量 ... */ .section ".text","ax",%progbits .global Reset_Handler Reset_Handler: /* 1. 设置主栈指针 MSP */ ldr r0, =__initial_sp msr msp, r0 /* 2. 调用 C 库初始化函数 __main */ ldr r0, =__main blx r0 /* 3. 如果 __main 返回(正常情况不会),跳转到 main */ ldr r0, =main bx r0 /* 4. 未定义的中断处理,进入死循环 */ .weak NMI_Handler .thumb_set NMI_Handler,Default_Handler /* ... 其他弱定义 Handler ... */ .section ".text.libc_init_array","ax",%progbits .global __libc_init_array __libc_init_array: .word __preinit_array_start .word __preinit_array_end .word __init_array_start .word __init_array_end .word __fini_array_start .word __fini_array_end这里最易被忽视的细节是__main的调用。__main是 ARM C 库提供的一个汇编函数,它内部会按顺序执行:
__scatterload:遍历.data段的 scatter table,将 Flash 中的.dataROM copy 复制到 RAM 中的.dataRAM copy;__rt_bss_zero:将.bss段从_sbss到_ebss的所有字节清零;__libc_init_array:遍历.init_array段中定义的所有函数指针,依次调用它们(这些通常是 C++ 全局对象的构造函数,或用户注册的初始化函数)。
如果__main没被调用,或者调用失败(比如栈溢出),那么.data和.bss就永远不会被正确初始化,main函数看到的就是一片“脏”内存。我见过最离谱的案例:某开发者为了“节省启动时间”,注释掉了blx __main,然后在main开头手动memcpy.data,但他忘了.data的 ROM copy 地址是__data_load_start__,而他在代码里写死了0x08002000,结果在不同编译条件下,这个地址经常错位,导致.data复制不全。
注意:
__main是 ARMCC/ARMCLANG 的符号。如果你用的是 GNU Arm GCC,对应的函数名是__libc_init_array,但它的调用时机是在Reset_Handler里,由crt0.o(C Runtime Object)提供。GCC 的启动流程是:Reset_Handler→__libc_init_array→main。所以,无论用哪个工具链,__main或__libc_init_array都是不可跳过的“点火开关”。
3.3 系统调用桩(Syscalls):C 库与硬件的“翻译官”
printf、scanf、fopen这些函数,最终都要落到对硬件的操作上。但 C 标准库本身并不知道你的 UART 是 USART1 还是 USART2,也不知道你的文件系统是 FatFS 还是 LittleFS。它只提供了一套统一的、与硬件无关的接口,叫做System Calls(系统调用)。这些调用在 newlib 中以_开头的函数形式存在,你必须为它们提供具体的硬件实现,这个过程就叫“打桩”(stubbing)。
最核心的四个桩是:
_write(int fd, char *ptr, int len):向文件描述符fd写入len字节数据。对于printf,fd通常是1(stdout),你需要在这里把ptr中的数据,通过 UART 的发送寄存器(如USART1->TDR)逐字节发送出去。_read(int fd, char *ptr, int len):从文件描述符fd读取数据。对于scanf,fd是0(stdin),你需要从 UART 接收寄存器(如USART1->RDR)读取数据。_sbrk(ptrdiff_t incr):为malloc分配或释放 heap 空间。incr是请求增加的字节数(正数)或减少的字节数(负数)。你需要维护一个全局的heap_brk指针,初始值为__heap_base,每次调用时将其增加incr,并返回旧值。关键是要检查heap_brk + incr是否超过了__heap_limit,超了就返回(void*)-1。_close(int fd)、_lseek(int fd, int offset, int whence)、_fstat(int fd, struct stat *st):这些是文件操作的辅助调用,如果只用printf,可以简化为return 0;。
一个健壮的_write实现,必须考虑阻塞与非阻塞。裸机环境下,通常用轮询方式等待 UART 发送完成:
#include <sys/stat.h> #include "stm32f4xx_hal.h" extern char __heap_base; extern char __heap_limit; // 全局 heap 指针 static char *heap_brk = &__heap_base; // _sbrk 实现 caddr_t _sbrk(ptrdiff_t incr) { char *prev_brk = heap_brk; if (heap_brk + incr > &__heap_limit) { return (caddr_t)-1; // Heap overflow } heap_brk += incr; return (caddr_t)prev_brk; } // _write 实现,阻塞式 UART 发送 int _write(int fd, char *ptr, int len) { if (fd != STDOUT_FILENO && fd != STDERR_FILENO) return -1; for (int i = 0; i < len; i++) { while (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC) == RESET) { // 等待发送完成标志 } huart1.Instance->TDR = (uint8_t)ptr[i]; } return len; }实操心得:
_write的阻塞实现,在多任务环境下是灾难性的。如果一个低优先级任务在printf,它会一直占用 CPU 等待 UART 发送完成,导致高优先级任务无法及时响应。解决方案是:在 RTOS 下,将_write改为向一个专用的“log 任务”发送消息,由该任务负责实际的 UART 发送。这样,printf调用几乎是瞬时返回的,不会阻塞任务调度。
3.4 errno 与 _reent:多任务下的“个人档案”
errno的线程安全,是 runtime 多任务适配中最体现功力的部分。newlib 为此设计了一个叫struct _reent的结构体,它包含了errno、stdio的缓冲区、malloc的状态等所有与单个执行流相关的数据。每个任务都应该拥有自己独立的_reent实例。
在 FreeRTOS 中,标准做法是在FreeRTOSConfig.h中定义:
#define configUSE_NEWLIB_REENTRANT 1然后,在portable/ThirdParty/GCC/ARM_CM4F/port.c的xPortStartScheduler()中,为每个任务的 TCB 分配一个_reent结构体,并在vTaskSwitchContext()中更新全局的_impure_ptr:
// 在 TCB 结构体中添加 typedef struct tskTaskControlBlock { volatile StackType_t *pxTopOfStack; ListItem_t xStateListItem; // ... 其他字段 struct _reent xNewlibReent; // 每个任务的私有 _reent } tskTCB; // 任务切换时,更新 _impure_ptr void vTaskSwitchContext( void ) { // ... 其他切换逻辑 _impure_ptr = &( pxCurrentTCB->xNewlibReent ); }这样,当 Task A 调用open()失败时,errno被写入TaskA->xNewlibReent._errno;当 Task B 调用read()失败时,errno被写入TaskB->xNewlibReent._errno。两者完全隔离,互不影响。
提示:
configUSE_NEWLIB_REENTRANT是 FreeRTOS 提供的便捷宏,它背后就是上述的 TCB 扩展和切换钩子。如果你用的是其他 RTOS,或者想完全手动控制,就必须自己实现__getreent()函数,让它根据当前任务 ID 返回对应的_reent地址。这要求你对 RTOS 的任务调度机制有深入理解。
4. 实操过程:从零开始构建一个可验证的 runtime 环境
4.1 环境准备:工具链与芯片选型
本次实操,我们选用最主流的组合:STM32F407VG(Cortex-M4) + GNU Arm Embedded Toolchain(gcc-arm-none-eabi-10.3-2021.10) + OpenOCD + VSCode(Cortex-Debug 插件)。这个组合的优势是开源、免费、社区支持强大,且与商业 IDE(Keil/IAR)的底层原理完全一致,学到的知识可无缝迁移。
第一步,安装工具链。从 https://developer.arm.com/tools-and-software/open-source-software/developer-tools/gnu-toolchain/gnu-rm 下载最新版gcc-arm-none-eabi,解压后将bin目录加入系统PATH。验证安装:
arm-none-eabi-gcc --version # 应输出类似:arm-none-eabi-gcc (GNU Arm Embedded Toolchain 10.3-2021.10) 10.3.1 20210824 (release)第二步,获取 STM32 标准外设库或 HAL 库。我们选择更底层、更可控的STM32CubeF4 v1.26.2,从中提取Drivers/CMSIS/Device/ST/STM32F4xx/Source/Templates/gcc/startup_stm32f407xx.s和STM32F407VGTx_FLASH.ld作为基础模板。
第三步,创建项目目录结构:
my_rt_project/ ├── src/ │ ├── main.c │ ├── system_stm32f4xx.c │ └── stm32f4xx_it.c ├── startup/ │ └── startup_stm32f407xx.s # 修改后的启动文件 ├── ld/ │ └── STM32F407VGTx_FLASH.ld # 修改后的链接脚本 ├── Makefile └── .vscode/ └── launch.json # OpenOCD 调试配置注意:不要直接使用 CubeMX 生成的工程。CubeMX 生成的启动文件和链接脚本虽然能用,但隐藏了大量细节,不利于我们理解 runtime。我们要从“裸”模板开始,亲手补全每一个环节。
4.2 第一步:让.data和.bss正确初始化
创建src/main.c,内容极其简单,只为验证初始化:
#include <stdio.h> // 已初始化的全局变量 int global_init = 0x12345678; // 未初始化的全局变量 int global_uninit; // 已初始化的静态变量 static int static_init = 0x87654321; // 未初始化的静态变量 static int static_uninit; int main(void) { // 初始化系统时钟(略) SystemInit(); // 打印所有变量的值,验证初始化是否成功 printf("global_init = 0x%08X\n", global_init); printf("global_uninit = 0x%08X\n", global_uninit); printf("static_init = 0x%08X\n", static_init); printf("static_uninit = 0x%08X\n", static_uninit); while(1) { // 主循环 } }此时,printf还不能用,因为我们还没实现_write。所以,先用一个最原始的方法:通过调试器观察内存。
编译前,先修改ld/STM32F407VGTx_FLASH.ld,确保.data和.bss的符号定义正确:
/* 在 SECTIONS 末尾添加 */ __data_start__ = _sdata; __data_end__ = _edata; __data_load_start__ = _etext; __bss_start__ = _sbss; __bss_end__ = _ebss;然后,在startup/startup_stm32f407xx.s的Reset_Handler中,手动添加.data复制和.bss清零代码(绕过__main,以便我们彻底掌控):
Reset_Handler: /* 1. 设置 MSP */ ldr r0, =__initial_sp msr msp, r0 /* 2. 手动复制 .data */ ldr r0, =__data_start__ ldr r1, =__data_end__ ldr r2, =__data_load_start__ cmp r0, r1 beq data_copy_done data_copy_loop: ldrb r3, [r2], #1 strb r3, [r0], #1 cmp r0, r1 blt data_copy_loop data_copy_done: /* 3. 手动清零 .bss */ ldr r0, =__bss_start__ ldr r1, =__bss_end__ movs r2, #0 cmp r0, r1 beq bss_zero_done bss_zero_loop: strb r2, [r0], #1 cmp r0, r1 blt bss_zero_loop bss_zero_done: /* 4. 跳转