系列说明:本文是阿栈的《 GHS MULTI 实战系列》第 05 期,也是全系列信息密度最高的一篇。
上期回顾:《04_链接脚本(下)_SECTIONS与自定义段》

摘要

"上电就跑飞"是嵌入式最经典、也最难查的问题——因为你写的 main() 一行都还没执行,所有 printf、所有断点、所有日志都不存在。

要查这类问题,必须知道从复位向量到 main 之间那段代码干了什么。它不是编译器"自动帮你搞定"的魔法,它有明确的名字、位置和执行顺序:

RESET → 取入口 → crt0(汇编)→ __lowinit / __ghs_board_memory_init
      → __ghs_ind_crt0(C)→ __ghs_ind_crt1(C)→ main → exit

在这里插入图片描述

本篇把这条链逐段拆到源码级,重点讲清六件事:

  1. 入口点到底是谁——_start?_start_T?还是零地址?有个优先级表;
  2. crt0 顶部那堆 .weak 是钩子——可以接管,但接管失败不会报错;
  3. .bss 清零和 .data 拷贝不是硬编码循环,是查表,而且不是一张表,是三对表;
  4. PIC/PID/PIR 三个基址是干什么的,以及 baseptrs 怎么从汇编传到 C;
  5. ind_crt1.c 是一张弱函数清单——你可以在 main 之前插代码,不用改官方文件;
  6. 启动要多久、跑飞了怎么五段定位——把前面所有原理变成可执行动作。

关键词:GHS 启动文件、crt0、ind_crt0、ind_crt1、__lowinit、__ghs_board_memory_init、BSS 清零、ROM 到 RAM 拷贝、弱函数、-e 入口、PIC base。

一、先建立分层:两层,各自能改到什么程度

层文件位置特点能不能改
架构相关层startup*.850(V850/RH850)、crt0.arm、crt0.ppclibstartup.a汇编,设默认寄存器、算 PIC/PID 指针、装向量表基本不改。Renesas 明确要求 RESET 之后直接用他们提供的启动代码
架构无关层ind_crt0.c、ind_crt1.clibstartup.a / libsys.aC,按段初始化内存、搭 C 运行环境可以改,而且扩展时应该改这一层

一句很实在的评语:

由于 _start 是用汇编写的,没有太多优化代码大小或执行速度的余地。

换句话说:别指望优化启动代码来省 Flash,要省就往别处省。

💡 第 02 期讲过:建 Program 时勾了 startup / system,这两个库的源码会落到你工程目录里,可以读可以改;不勾就只能用二进制库。想跟着本篇读源码,先去确认你当初勾了没有。

顺便说清一个边界:ind_crt0.c 在 libstartup.a,ind_crt1.c 在 libsys.a。跨库的——它决定了"你改哪个库能生效"。

二、RESET 之后,硬件先做了什么

在讨论 _start 之前,有一层更靠前:复位那一刻,CPU 是怎么找到第一行代码的?

答案是"硬件固定取址",你改不了:

架构复位后从哪取入口说明
RH850 / V850地址 0x00000000该地址存放复位处理函数的入口地址。链接脚本 flash_rsvd1 : ORIGIN = 0x00000000 就是这条线的证据
Cortex-M(ARM)地址 0x00000000 取初始 SP,0x00000004 取复位向量第一项是栈指针不是代码,这是 Cortex-M 与其它架构最大的差异
PowerPC(MPC57xx)Boot Header(RCHW)指定多了"选引导核"这一步,第 12 节展开
【RH850 / V850】只需一次取址
0x00000000 ┌────────────────────────┐
           │ 复位入口地址            │ ← 硬件取出来,直接跳过去
0x00000004 ├────────────────────────┤
           │ (向量区继续)          │
           └────────────────────────┘

【Cortex-M】需要两次:先装栈,再跳
0x00000000 ┌────────────────────────┐
           │ 初始 SP 值              │ ← 硬件取出来装进 sp
0x00000004 ├────────────────────────┤
           │ 复位向量                │ ← 硬件取出来跳过去
           └────────────────────────┘

两种模型有个实际后果:Cortex-M 复位后栈指针是硬件装好的(从地址 0 取),所以它的 crt0 里有"没找到 MULTI 就自己设栈"的分支;RH850 则由 startup*.850 显式装 sp。这也解释了为什么两份启动代码的"设栈"位置不一样——不是风格差异,是硬件差异。

⚠️ 本篇后面讲"入口点优先级"时说的"零地址兜底",和这张表是同一件事的两面:链接器把 ELF 的 e_entry 设成 0,调试器按 e_entry 启动,于是又跳回了复位向量——你看到的现象是"程序一启动就回到最开始",像反复重启,而不是停在某一行。

三、入口点:第一条指令到底从哪来

一张优先级表,非常好用。前提是"没有特别指定 entry point":

优先级符号说明
1_startGHS 标准入口
2start老式写法
3_main—
4main没有启动文件时直接进 main(几乎只用于特殊实验)
5零地址前面都没有时,去地址 0

两个特殊情况:

  • 只支持 Thumb 指令的架构(Cortex-M)默认入口是 _start_T,不是 _start——因为它们复位后不在 ARM 态;
  • 要自定义入口:Set Build Options → All Options → linker → **start and end files**,或在链接脚本里写 OPTION("-e Reset_Handler")。两者只能选一个,同时写会冲突。

是

否

有

无

有

无

有

无

覆盖默认

链接器要定 entry

显式指定 -e ?

用 -e 指定的符号

有 _start ?

_start 优先级 1

有 start ?

start 优先级 2

_main 或 main ?

_main 或 main

零地址 危险

Thumb-only 架构 Cortex-M

_start_T

图 1|入口点选择顺序。"零地址"是最后的兜底,也是"程序跑飞"的常见结局——结合第二节,它意味着回到复位向量。

怎么查当前工程用的是哪个入口(比猜快得多):看 map 文件里是否有 _start 这一行符号、或者用 gdump/nm 类工具看 ELF 入口。符号在、但没被用作入口,说明有人用 -e 覆盖了。

四、crt0 逐段解剖(以 ARM 版为例)

下面这段 crt0.arm,我按执行顺序切成七段。RH850 的 crt0.850 结构同源:弱符号清单、检测 MULTI、__ghs_board_memory_init、跳到 ind_crt0 这几步一样,只是寄存器名换成 r6/r7,开 FPU 换成设 FPCSR。

4.1 第一段:一堆 .weak——这就是预留的钩子

    .globl _start_T
_start_T:
    .need exit
    .need __dotsyscall

文件头部还有一长串:

    .weak __ghsbegin_picbase
    .weak __ghsbegin_robase
    .weak __ghsbegin_pidbase
    .weak __ghs_rambootcodestart
    .weak __ghs_rambootcodeend
    .weak __ghs_rombootcodestart
    .weak __ghs_rombootcodeend
    .weak __ghsbegin_tracebuf
    .weak __ghs_do_fp_init
    .weak __ghs_board_memory_init
    .weak __ghs_initial_dotsyscall

这一堆 .weak 就是启动代码的可插拔接口。 这些符号在链接脚本里可能不存在(比如你没有 .tracebuf 段),弱声明让汇编能编过;你在自己的 C/汇编里定义同名强符号,就自动接管。

顺带解释 .need 指令:它的意思是"即使看起来没用到,也必须把这两个符号链进来"。exit 和 __dotsyscall 是启动代码的硬依赖,.need 保证了这一点。

💡 阿栈点评:这解释了第 04 期那个 __ghs_rombootcodestart 为什么"建议不要删"。它在这里被 .weak 引用,链接脚本里彻底没有这个符号时会取到 0——而启动代码用它算 boot code 范围。取到 0 不报错,只是算错。

4.2 第二段:保存现场——argc/argv/envp 的两条命运

    ; Save argc, argv, envp (if not under Multi, these are meaningless)
    mov r6, r0
    mov r7, r1
    mov r8, r2

注释里那句括号很关键:“if not under Multi, these are meaningless”。

R0/R1/R2 里的东西是调试器传进来的(MULTI 能把命令行参数传给目标)。脱机运行时这三个寄存器里是垃圾。所以启动代码把它们先存到 r6/r7/r8,等确认"上面是不是 MULTI"之后再决定用不用:

情况后续动作main 收到的参数
在 MULTI 下运行把 r6/r7/r8 还原到 r1/r2/r3调试器传的真实参数
脱机运行走 setupregs,把 r1/r2/r3 清 0argc = 0

然后这个 argc = 0 会被 ind_crt1 接手修掉——第八节 8.2 讲。这条链跨了三个文件(crt0.arm → ind_crt0.c → ind_crt1.c),是"阅读启动代码必须连着看"的最好例子。

4.3 第三段:__ghs_board_memory_init——第一个"静默跳过"

    ; initialize SDRAM a chance to initialize if we're running from flash
    ; NOTE: elxr will jump to the next instruction if no __ghs_board_memory_init
    bl __ghs_board_memory_init

注释里那句 NOTE 是全篇最该划重点的一行:

elxr will jump to the next instruction if no __ghs_board_memory_init

翻译:如果你没定义这个函数,链接器(elxr)就让这条 bl 变成"跳到下一条指令"——不报错、不警告、无事发生。

说明:

__ghs_board_memory_init 是一个弱定义函数,用户可以根据需要自己完成该函数,建议使用汇编。推荐该函数实现设置中断向量表和启动前设置操作。如果用户没有在外部定义该函数,此句为空将跳过。

这和 RH850 上的 __lowinit 是完全同一类坑:一个"可选钩子",没实现就静默跳过,而它负责的往往是"时钟 / 向量表 / SDRAM / ECC"这种没配也能跑、但跑得不对的东西。

钩子架构该干的事没实现的表现
__lowinitRH850 / V850时钟树、关键 SFR跑在默认时钟上,波特率/定时全错
__ghs_board_memory_initARM / PowerPC装向量表(VTOR)、SDRAM/ECC 初始化向量表指向默认位置;从 RAM 镜像启动时中断全飞
__ghs_board_devices_init通用(C)关键寄存器、默认运行时钟外设时钟没开
__ghs_do_fp_init部分架构浮点单元初始化浮点指令异常或结果错

4.4 第四段:SYSCALL_HELLO——为什么 printf 只在调试器里有输出

    mov r0, SYSCALL_HELLO     ; Set call type
    mov r1, 1                 ; Another argument
    bl __ghs_initial_dotsyscall
    ...
    cmp r0, 0                 ; Did we find Multi?
    bne setupregs             ; 没找到 Multi → 走 setupregs(自己设栈)

解读:

此段代码是设置了系统调用(bl __ghs_initial_dotsyscall)并检查是否运行了 MULTI 的调试器,如果使用第三方调试器,将进入到 setupregs 代码段,否则继续执行,还原最开始的保存现场的值。

这就是"调试器里 printf 有输出、脱机一点没有"的机制级解释:SYSCALL_HELLO 在问"上面是不是 MULTI?"。是 → 走调试器的 syscall 通道(半主机);不是 → 自己设栈、清参数,printf 的输出没有去处。

要注意这个判断的返回值还决定了另一件事:栈指针要不要自己装。也就是说"MULTI 在不在"这一个标志,同时决定了"参数从哪来"和"栈从哪来"。

⚠️ 裸机上 printf 想真正输出,必须重定向到串口(通常实现 __gh_iob_init 或改写底层 write)。靠调试器通道只是开发期便利,不是产品能力。

4.5 第五段:picbase = 实际地址 − 链接时地址

    ; picbias = actualaddress - linktime address
    ; 4 is added to the linktime address, to account for the extra 4 added
    ; to the PC (r15) when read, due to the pipeline
    ldr r5, pool_myaddr
    sub r5, r5, 1        ; unset the low order bit
.Lpicbase:
myaddr:
    mov r2, pc
    sub r5, r2, r5       ; Now r5 contains picbase

三个细节:

细节含义
sub r5, r5, 1 注释 “unset the low order bit”Thumb 地址的最低位是模式标志(1 = Thumb),清掉它才是真实地址
mov r2, pc读当前 PC,得到运行时的真实地址
sub r5, r2, r5真实地址 − 链接时地址 = 偏移量(picbase)

为什么需要这个偏移:如果你的程序"链接在 A 地址、实际跑在 B 地址"(Bootloader 跳到 App、RAM 镜像、重定位加载都是这种情况),那么所有"链接时算好的绝对地址"全都不对,必须整体加一个偏移。这个偏移就是 picbase。

💡 阿栈点评:这段是 Bootloader 开发者最该读懂的一段。"App 链接在 0x00010000,Bootloader 把它搬到 0x00020000 跑"这种设计,能不能跑取决于 picbase 算得对不对。 算错了的表现是:代码能执行(相对跳转没问题),但一读全局变量就错(绝对地址没修正)。

4.6 第六段:设栈、开 FPU、初始化全局寄存器

setupregs:
    ldr r0, pool_stack
    mov sp, r0
    mov r1, 0        ; Clear argc
    mov r2, 0        ; Clear argv
    mov r3, 0        ; Clear envp

regsokay:
    movw r0, 0xED88  ; CPACR at 0xE000ED88
    movt r0, 0xE000
    ldr r4, [r0]
    orr r4, r4, #(15<<20)  ; 置位 20-23,使能 CP10/CP11
    str r4, [r0]

三个动作:

动作意义RH850 对应
ldr r0, pool_stack; mov sp, r0从链接脚本给的 __ghsend_stack 装 SPstartup*.850 里装 sp
CPACR 置位使能协处理器 CP10/CP11(浮点)改 FPCSR / 使能 FPU
mov r1/r2/r3, 0脱机时把参数清零同理

对 FPU 一句直白的说明:“以上代码为设置 FPU,用户可以按照需求进行删改。” 也就是说它不是不可动的骨架——不用浮点的工程,这段可以删;用了浮点但想自己控制的,可以改。

关于栈,有一个常见误解要澄清:启动代码不"初始化"栈,只是把 SP 指向 __ghsend_stack。栈里的内容是上电残留值,不清零。所以"栈上未初始化的局部变量是随机值"这件事,是设计如此,不是 bug。第 04 期说 .stack 用 PAD 不用 CLEAR,这里是它的执行侧印证。

4.7 第七段:把 baseptrs 交给 C(这一段最容易被漏掉)

    /* VFP && __THUMB2_AWARE */
    ldr r0, pool_baseptrs    ; Could use add r0, pc, offset if assembler would take it
    add r0, r0, r5           ; Add in picbase

    ; Initialize global regs
    ldr r7, pool_regtab
    add r7, r7, r5
    ldr r4, [r7]
    ldr r5, [r7, 4]
    ldr r6, [r7, 8]

    ; This is where control merges back together.
    bl __ghs_ind_crt0        ; Go to C land

这一段是全篇的接缝,容易漏掉:

  1. ldr r0, pool_baseptrs + add r0, r0, r5 → 把 baseptrs 的地址加上 picbase,放进 r0。r0 就是 __ghs_ind_crt0 的第一个参数(char *baseptrs[]);
  2. regtab 里连取三个值到 r4/r5/r6 → 初始化全局寄存器(GHS 的"global regs"优化,把常用值常驻寄存器);
  3. bl __ghs_ind_crt0 → 进第一个 C 函数。

所以两个函数的签名能对上:

/* 汇编这边:r0 = baseptrs, r1 = argc, r2 = argv, r3 = envp */
void __ghs_ind_crt0(char *baseptrs[], int argc, char *argv[], char *envp[]);

baseptrs 里装的就是 PIC/PID/PIR 那几个基址,由 crt0 算好、通过这个数组交给 C 层。这样 ind_crt0.c 里才有 BASE[4] 和 picbase/pidbase 可用——下一节展开。

💡 顺带说一句 regtab:它不是一个 C 变量,从名字和用法看是全局寄存器的初值表(GHS 的 global regs 特性:把常用值常驻在某些寄存器里)。你在 C 里看不到它,但它会改变寄存器分配。这也是为什么"启动代码里改一行,全工程性能可能变"——它动的是全局寄存器的初始状态。

五、__lowinit:RH850/V850 上最危险的一个函数

在 RH850/V850 上,这个前置钩子叫 __lowinit,而不是 __ghs_board_memory_init。

5.1 它的定位

Renesas 教程原文很明确:

  • RESET 会调用一个名为 __lowinit() 的子过程;
  • 如果 __lowinit 不存在,不会发出警告,执行直接继续到 _start;
  • 用途是初始化时钟树或其他重要的 SFR;
  • __lowinit 例程可以用 C 实现。

5.2 为什么说它危险

你写了一版 __lowinit 想配 PLL,但由于签名不匹配 / 编译选项把它优化掉了 / 名字拼错,它根本没被链接进来。程序不会报错,会若无其事地继续跑——跑在一个你完全没预期的时钟频率上。

接下来发生的事:

症状机理
串口波特率全错时钟不对,分频算出来的波特率就是错的
定时器周期不对所有周期性任务的时间基准都错
Flash 等待周期与实际频率不匹配读 Flash 读到错数据
看门狗超时时间不对可能一直在复位,表现为"程序起不来"

而这些问题在 main 里打断点看变量是看不出来的——变量都是对的,只是时间不对。

5.3 怎么确认它真的被执行了

方法操作判据
GPIO 打点(最可靠)在 __lowinit 第一行翻转一个空闲脚示波器看到翻转 = 进来了
断点在调试器里对它下断点命不中 = 没链接进来
写魔数往一个固定 RAM 地址写 0xA5A5A5A5调试器里读到 = 执行过
反查符号map 里搜 __lowinit有符号、且地址在 .text 里

💡 阿栈点评:我见过不止一个项目,__lowinit 的链接属性不对,导致时钟始终工作在内部 RC 而不是外部晶振,性能只有设计值的几分之一。因为"功能都正常",谁都没发现,直到 EMC 测试指标全线不合格才回头查到这里。上电第一件事,先用示波器确认系统时钟频率。

六、ind_crt0.c:六张表、三对指针

这是本篇的技术核心。很多人以为启动代码是"两个 for 循环",实际不是——它是查表,而且是三对表。

6.1 函数签名

void __ghs_ind_crt0(char *baseptrs[], int argc, char *argv[], char *envp[])
{
    size_t  BASE[4];
    caddr_t picbase, robase, pidbase;
    caddr_t rambootcodestart, rambootcodeend;
    caddr_t rombootcodestart, rombootcodeend;
    ...
}

一个容易踩的点:

注意此处的 rambootcodestart 等变量没有下划线(_)开头,这几个变量实际是一个 (size_t) 类型。

链接脚本里是 __ghs_rambootcodestart(有下划线),C 函数里是局部变量 rambootcodestart(无下划线),两者不是一回事。看源码时别搞混。

还有 #if !defined(MINIMAL_STARTUP) —— 这行意味着存在一个"最小启动"变体,它砍掉了一部分表处理。如果你在工程里定义了 MINIMAL_STARTUP,本篇讲的很多步骤是不执行的。这是个隐形的开关,排查启动问题时值得先搜一下有没有人定义它。

6.2 完整表清单:三对,不是一张

两段代码,但把符号名连起来看,一共是六张表头符号、三对:

表起点分界点终点干什么
清 BSS__ghsbinfo_clear__ghsbinfo_aclear__ghseinfo_clear该清零的段
ROM→RAM 拷贝__ghsbinfo_copy__ghsbinfo_tcopy__ghseinfo_copy.data、ROM() 段
CROM 解压拷贝__ghsbinfo_comcopy__ghsbinfo_comtcopy__ghseinfo_comcopyCROM() 段

三对表结构完全一样,都是"起点 → 分界 → 终点"三段式。 这个模式一旦看出来,后面就不用逐字读了:一张表在内存里是连续的,中间插一个指针把它切成两批。

段从哪到哪地址怎么算
前一批info_xxx → 分界指针要加上基址修正(PIDBASE / PICBASE)
后一批分界指针 → einfo_xxx用绝对地址,不加修正

info / einfo 是 begin / end 的缩写,t 是 text(代码)的缩写,a 是 additional/absolute 的缩写。分界指针的作用就是:同一张表切成两批,前一批要修正,后一批不用。

6.3 清 BSS:记录是 (目标, 填充值, 长度)

/* Clear BSS */
{
    void **b = (void **)((char *)__ghs_funcadjust(__ghsbinfo_clear) + SCIFIX);
    void **e = (void **)((char *)__ghs_funcadjust(__ghseinfo_clear) + SCIFIX);
    void **a = __ghs_funcadjust(__ghsbinfo_aclear)
             ? ((void **)((char *)__ghs_funcadjust(__ghsbinfo_aclear) + SCIFIX))
             : e;

    /* Warning: This code assumes
       __ghsbinfo_clear <= __ghsbinfo_aclear <= __ghseinfo_clear
       Which is currently enforced with elxr OR
       __ghsbinfo_aclear == 0 (i.e.: undefined) */

    int OFFSET = PIDBASE;

    while (b != e) {
        void *t; ptrdiff_t v; size_t n;
        while (b != a) {
            t = OFFSET + (char *)(*b++);
            v = *((ptrdiff_t *)b); b++;
            n = *((size_t *)b);    b++;
            (*memsetp)(t, v, n);
        }
        OFFSET = 0;
        a = e;
    }
}

读法:

概念含义
表里每条是三元组 (目标地址, 填充值, 长度)所以循环里连续取三次指针
while (b != a) → OFFSET = 0 → a = e两遍循环:第一遍带 PIDBASE 偏移,第二遍绝对地址
a 指向不存在时(== 0)直接令 a = e,等于跳过第一遍
__ghs_funcadjust + SCIFIXPIC 情况下把"链接时地址"换算成"运行时地址"

两个值得注意的点:

  1. 记录里带"填充值",不只是清零。代码调的是 memset(t, v, n),v 来自表。理论上这套机制能填任意值;不过链接器对 CLEAR 段填的是 0,看到这个 v 你应该理解成"机制上更通用",而不是"我能随便填"。
  2. 那张注释里的"Warning"是给链接器作者看的约束:clear <= aclear <= einfo_clear。也就是说两张表在内存里是连续的,靠一个指针切分。如果你手工改链接脚本把这两张表分开了(比如把某批段挪到别的内存块),这个假设就破了。

关键结论(第 04 期的正面印证):

SECTIONS 里一个段带不带 CLEAR,决定了它上电后是不是 0。 你用 #pragma ghs section bss=".mypool" 造了个自定义段,但脚本里没给它 CLEAR,它就不会被清零——那里面全是上电残留值。

6.4 拷贝:记录是 (目标, 源, 长度)

/* Copy from ROM to RAM */
{
    void **b = (void **)((char *)__ghs_funcadjust(__ghsbinfo_copy) + SCIFIX);
    void **m = (void **)((char *)__ghs_funcadjust(__ghsbinfo_tcopy) + SCIFIX);
    void **e = (void **)((char *)__ghs_funcadjust(__ghseinfo_copy) + SCIFIX);

    while (b != e) {
        void *t; void *s; size_t n;
        t = ((b < m) ? PIDBASE : PICBASE) + (char *)(*b); b++;
        s = PIRBASE + (char *)(*b++);
        n = *((size_t *)b); b++;
        ...
    }
}

和清 BSS 的差别只有一个字:第三元是 n(长度),不是 v(填充值);同时多了 s(源地址)。

(b < m) 那次判断决定了"目标地址加哪个基址":前半张表是数据(PIDBASE),后半张是代码(PICBASE)。这就是为什么 __ghsbinfo_tcopy 名字里有 t——t = text。

这就是第 04 期 CROM(.data) / ROM(.mydata) 的实际执行者:链接脚本负责"登记",这里负责"执行"。 你写的那两行 SECTIONS,最终变成这张表里的一条记录。

6.5 CROM 解压:弱符号提升机制

if (__ghs_weak_func_exists(__ghs_decompress)) {
    ...
}

If no CROM sections are present, the linker will not promote the weak symbol __ghs_decompress to strong and __ghs_decompress will not be linked into the program.

用了 CROM 才有解压代码——不用就一点空间都不占。这也是为什么 04 期说"不用 CROM 是零成本的":它的成本不在体积,在启动时间(解压要花时间,下一节算)。

6.6 最后一跳

(*crt1p)(argc, argv, envp);

注明:crt1p 指向 __ghs_ind_crt1,已不属于 libstartup.a,而是 libsys.a(ind_crt1.c)。跨库了,注意这个边界。

七、PIC / PID / PIR:三个基址到底是什么

上一节反复出现 PIDBASE、PICBASE、PIRBASE,这里一次说清。这是启动代码里最容易"看着眼熟但不知道在干嘛"的三个词。

基址管什么用在哪不加会怎样
PICBASE代码的运行基址代码段重定位函数调用跳到链接时地址(错的)
PIDBASE数据的运行基址可写数据重定位全局变量读写落在链接时地址
PIRBASE**ROM 侧(源)**的基址拷贝源地址从错误的 ROM 地址取初值,拷进去的是垃圾

为什么代码和数据要分开两个基址?因为它们在运行时可以落在不同区域:代码可能还在 Flash(XIP),而数据一定在 RAM。两者相对链接时地址的偏移量不同,所以必须分开算。

⚠️ 三个缩写这里按用途命名(PIC/PID 分别对应 code / data,PIR 对应拷贝源),目的是让你记的时候有抓手。如果要在评审里引用,请以你版本编译器的 build_xxx.pdf 中 “PIC” 相关章节的原文定义为准,别引用这个中文解释。

区域链接时运行时用哪个基址
代码 .text链接地址可能整体被搬到别处(重定位、RAM 镜像)PICBASE
数据 .data跟在代码后面一定在 RAM,与链接地址完全不同PIDBASE
拷贝源(Flash 里的初值)在 Flash 内仍在 Flash 内PIRBASE

表格里不填具体数字,是因为偏移量取决于你这次运行到底把它搬到了哪——这正是"基址"这个概念存在的原因。

另外两个符号:

符号作用说明
__ghs_funcadjust(p)把"链接时的函数/数据地址"换算成"运行时地址"表里的指针存的是链接时地址,用之前都要过一遍它
SCIFIX加在信息表指针上的修正量从用法看是 PIC 场景下的表内偏移修正

💡 阿栈点评:这一节和 04 期的 .sdabase 是同一个思路的两端——"基址"这件事在 GHS 里出现了三次:small data 有基址(.sdabase)、代码有基址(PICBASE)、数据有基址(PIDBASE)。理解"为什么需要基址",比记住这三个名字有用:因为链接时的地址和运行时的地址可以不一样。

八、ind_crt1.c:一张弱函数清单

这是全篇最实用的一节。ind_crt1 干的事可以一句话概括:把所有"可选初始化"挨个问一遍"你实现了吗",实现了就调用,没实现就跳过。

void __ghs_ind_crt1(int argc, char *argv[], char *envp[])
{
#if defined (__CRT_TRACE_INIT)
    { __ghs_trace_init(); }
#endif
    { if (__ghs_weak_func_exists(__ghs_board_devices_init))  __ghs_board_devices_init(); }
    { if (__ghs_weak_func_exists(__gh_lock_init))            __gh_lock_init(); }
    { if (__ghs_weak_func_exists(__ghs_malloc_init))         __ghs_malloc_init(); }
    { if (__ghs_weak_func_exists(__gh_iob_init))             __gh_iob_init(); }
    ...
    if (!argc) {
        noname[0] = 0; noname[1] = 0;
        arg[0] = noname; arg[1] = 0;
        env[0] = noname+1; env[1] = 0;
        envp = env; argv = arg; argc = 1;
    }
    environ = envp;
    exit(main(argc, argv, envp));
    _Exit(-1);
}

8.1 钩子全表

钩子函数何时被调用干什么能不能自己实现
__ghs_trace_init定义了 __CRT_TRACE_INIT初始化 trace一般不用
__ghs_board_devices_init总是(若存在)关键寄存器、默认运行时钟✅ 推荐
__gh_lock_init总是(若存在)锁机制初始化慎改
__ghs_malloc_init总是(若存在)malloc 库初始化用了动态内存再看
__gh_iob_init总是(若存在)标准输出(printf)通道初始化⚠️ 不熟悉勿改
__ghs_cpp_exception_initC++ 且非 __OSEC++ 异常C++ 项目注意
__ghs_manprf_init总是(若存在)手动 profiling 初始化⚠️ 不熟悉勿改
__ghs_coverage_init总是(若存在)覆盖率初始化⚠️ 不熟悉勿改
__ghs_set_stack_chk_guard非 __OSE栈保护哨兵用 -stack_protector 时关注
__ghs_validate_ghtws__arm__校验 .ghtws 段信息ARM 专用

直白的提醒:

__gh_iob_init() 为系统调用标准输出函数初始化函数,如果不熟悉,请勿自定义。
__ghs_manprf_init() 和 __ghs_coverage_init() 两个函数默认使用 MULTI 已经写好的函数,如果对该功能不熟悉的情况下,请勿自定义。此为测试代码覆盖率和运行性能部分。……在发布版本中,请勿使用该功能。

💡 阿栈点评:这张表的实战价值在于——"我想在 main 之前跑一段自己的代码"这件事,正确做法不是改 ind_crt1.c,而是实现 __ghs_board_devices_init()。 前者会在你升级编译器时全盘重来,后者是官方预留接口,跨版本稳定。

选哪个钩子也有讲究:**要动"必须在 C 之前发生的事"(向量表基址、SDRAM)→ __ghs_board_memory_init(汇编);要动"普通外设"→ __ghs_board_devices_init(C,不用碰汇编)。

8.2 argc == 0 那段是干嘛的(补上 4.2 断掉的链)

if (!argc) 是在调试器没有传参数(脱机从 Flash 执行)时,构造一个合法的空 argv/envp。

接上 4.2 的因果链,完整故事是这样的:

crt0 发现"没有 MULTI" → r1/r2/r3 清 0 → ind_crt0 收到 argc=0
    → 原样传给 ind_crt1
        → ind_crt1 发现 argc==0 → 造一个空字符串当 argv[0] → argc=1
            → main(1, argv, envp)

所以裸机程序里 argv[0] 不是 NULL,而是一个空字符串——这就是 noname 干的。三个文件接力,最终目的是让 main 的签名始终成立。

⚠️ 一个实际后果:如果你的代码依赖 argc == 0 来判断"是不是脱机",那是错的——它一定是 1。要判断调试器在不在,得看别的东西(比如 SYSCALL_HELLO 的返回值,或者干脆自己打个标志)。

8.3 main 返回之后去哪了

注意这里不是 main(...),而是 exit(main(...))。

exit 会做三件事:跑 atexit 注册的函数、flush 标准输出缓冲、最后调 _Exit。所以:

场景结果
main 里 return 0走 exit → 刷缓冲 → _Exit(0) → 通常死循环或触发复位
main 里 while(1)永远不返回,exit 不会被调用
main 返回后还有代码exit(...) 之后的 _Exit(-1) 是兜底,正常不会执行

这意味着 main 里写了 return 的裸机程序,"看起来正常"其实是在跑 _Exit 的死循环。如果你的工程靠 main 返回后的行为做复位(比如某些看门狗策略),要确认 _Exit 到底做了什么——这跟你用的 C 库实现有关。

这也是为什么"裸机 main 最后要写 while(1)"不是风格问题——不写的话,控制权交给 exit,而 exit 的行为不是你能预期的。

九、自定义启动脚本:三种做法,官方只推荐一种

方式做法评价
① 完全自定义不用任何默认脚本,自己从零写最自由,但要把 .data 拷贝、.bss 清零、CROM 解压、PIC 处理全部自己实现
② 直接改官方脚本在 GHS 提供的脚本里改升级编译器时改动会丢
③ 重定义弱函数 / 自定义脚本里调默认脚本不动官方文件✅ GHS 推荐:最大限度利用默认脚本,升级不痛

GHS 推荐使用第三种方式,这样可以最大限度利用默认脚本实现的功能,帮助用户获取更多便利性。

9.1 做法 A:只重定义弱函数(最常见)

文件 1:init_isr_vector.arm —— 单独放向量表

    .file "init_isr_vector.arm"
    .import _start_T
    .import __ghsend_stack

    .section .isr_vector, "a"   ; naming the vector table
    ; __ghsautoimport_isr_vector causes it to be pulled in by the linker,
    ; even when part of a library, along with any dependencies.
    .globl __ghsautoimport_isr_vector
__ghsautoimport_isr_vector::
    .long __ghsend_stack          ; Stack
    .long _start_T                ; POR Reset Entry
    .long NMI_Handler             ; Non Maskable handler
    .long HardFault_Handler       ; SV Hard Fault handler
    ; ... 省略 ...
    .long SCM_CacheErr_IRQHandler ; irq 204
    .end

注意那个 .globl __ghsautoimport_isr_vector——这是 __ghsautoimport 关键字的实际用法:即使向量表被编在库里,链接器也会把它拉进来。没有它,向量表可能整段不被链接,表现为"所有中断都不响应"。

文件 2:initboard.arm —— 装向量表基址

    .file "initboard.arm"
    .section .reset_handler, "ax"
    .align 4
    .weak __ghsautoimport_isr_vector
    .set VTOR, 0xE000ED08

__ghs_board_memory_init::
    cpsid fi                          ; 先关中断
    ldr r0, =VTOR
    ldr r1, =__ghsautoimport_isr_vector
    str r1, [r0]                      ; 装向量表基址
    cpsie fi                          ; 恢复中断
    bx lr

    .scall __ghs_board_memory_init, __leaf__
    .maxstack __ghs_board_memory_init, 0
    .end

__ghsautoimport_board_init::
    .weak __ghs_board_memory_init

说明:

用户只需要增改 __ghs_board_memory_init() 内的指令,此函数仅用来初始化关键信息;如果需要设置一般寄存器或者其他外设,请实现 C 函数 __ghs_board_devices_init()。

两个汇编细节值得单独看:

指令作用忘了会怎样
.scall ... __leaf__告诉编译器"这是叶子函数,没有栈帧"栈用量统计偏大,-stack_check 误报
.maxstack ... 0声明该函数不占栈同上

这组声明在功能安全的栈分析里很重要——它直接影响工具算出来的最大栈深。手写汇编钩子时漏了它们,栈分析的结果会虚高,你可能会为此白白砍功能。

9.2 做法 B:自定义启动符号 + 清 SRAM ECC

有些芯片要求启动时先清 RAM 的 ECC,才能开中断/用 RAM。这种情况要自己定义一个 Reset_Handler,做完再跳回默认的 _start_T:

    .file "initboard.arm"
    .section .reset_handler, "ax"
    .align 4
    .weak Reset_Handler
    .set VTOR, 0xE000ED08

Reset_Handler::
    ; Clean SRAM ECC which is NEEDED to avoid ECC errors.
    ldr r0, =__ghs_ramstart
    ldr r1, =__ghs_ramend
    movs r2, 0
    movs r3, 0
1:  strd r2, r3, [r0], 8      ; 每次写 8 字节(两个寄存器 = 全总线宽度)
    cmp r0, r1
    blo 1b

    ldr r0, =_start_T
    bx r0

然后二选一指定入口:

编译选项:-e Reset_Handler
或 链接脚本:OPTION("-e Reset_Handler")

⚠️ 特别标注:两种方式只能选其一。同时写会冲突。

💡 阿栈点评:strd r2, r3, [r0], 8 这个"写满总线宽度"的细节,是 ECC 初始化的通用要求——ECC 是对整个总线字计算的,只写一半字节会留下未定义的校验位,于是你清完 ECC 反而制造了 ECC 错误。第 10 期讲 PowerPC 多核时同样的坑会再出现一次(那边是用"跳转到自身"的指令填充)。

另外注意这里用的是 __ghs_ramstart / __ghs_ramend——正是第 04 期链接脚本末尾那四个"不要删"的符号。删了它们,这段汇编的 ldr r0, =__ghs_ramstart 就取到 0,然后你把 0 地址往后写 256KB,后果自己想象。

9.3 选择建议

你的需求选哪种
只想加个向量表、改个时钟做法 A(重定义 __ghs_board_memory_init)
只想初始化外设、跑点 C 代码实现 __ghs_board_devices_init()(连汇编都不用)
RAM 要清 ECC、要特殊上电序列做法 B(自定义 Reset_Handler + -e)
用 AUTOSAR OS 接管启动属于"方式①",必须自己补齐 .data 拷贝和 .bss 清零,否则全局变量初始化全废
要做功能安全、需要可追溯做法 A(官方脚本不动,只加钩子,升级证据链干净)

十、启动耗时:从 RESET 到 main 要多久

这一节回答一个工程上很实际、但资料从不给数字的问题:启动到底花了多少时间? 车载 ECU 的"快速启动"要求动辄 50ms、100ms,而 .bss 清零往往是里面最大的一块。

10.1 三个耗时段

阶段耗时来源量级
__lowinit / board_memory_initPLL 锁定等待(几十~几百微秒)、SDRAM 初始化百微秒级
.bss 清零纯内存写,量 = .bss 大小毫秒级(大头)
.data 拷贝从 Flash 读 + 往 RAM 写毫秒级(量大时)
CROM 解压比拷贝多一步解压运算比 ROM() 慢 2~5 倍

10.2 算一下

假设:内核 100MHz,32 位总线,memset 每写 4 字节约 3 个周期(含循环开销):

.bss = 256KB = 262144 字节 = 65536 个 32 位字
周期数 ≈ 65536 × 3 = 196608
时间   ≈ 196608 / 100MHz ≈ 1.97ms

再算 .data 拷贝 16KB(每字"读 + 写"约 5 周期):

4096 字 × 5 = 20480 周期 ≈ 0.2ms

合计约 2.2ms。 看起来不多,但:

如果那就会
.bss 是 1MB约 8ms
主频只有 20MHz约 11ms(.bss 1MB 时 40ms+)
看门狗超时设成 1ms每 1ms 复位一次,永远进不了 main

最后一行是真实事故:“程序起不来、一直在复位”,根因是看门狗超时比 .bss 清零时间还短。 现象和"启动代码坏了"一模一样,实际是时间参数配错了。

10.3 想快就动这四个地方

手段效果代价
减小 .bss(大缓冲改成不清零的段)最有效,线性收益那块内存内容不确定,必须自己初始化
用 -O2 以上让 memset 展开成字写通常快 2~4 倍代码略大
不用 CROM() 装大表省掉解压时间Flash 占用变大
别在这条链上开 profiling/coverage少一大堆初始化失去调试能力(发布版本来也不该有)

关于"减小 .bss"要说得更具体:char big_buf[64*1024]; 这样的全局缓冲,默认进 .bss,每次上电都要清 64KB。如果它其实是个"用完即弃"的暂存区(比如通信帧缓冲,每次用之前都会覆盖),那清零纯属浪费——把它放进一个脚本里没给 CLEAR 的段,启动就能省下这块时间。

⚠️ 但这一步有前提:你得保证没有代码依赖"它是 0"。很多"偶发"bug 就是这么来的——昨天还在清零,今天为了快 2ms 不清了,某个漏初始化的标志位突然变成 1。改之前先全局搜这个缓冲的读操作。

十一、上电就跑飞:五段定位法

前面十节讲的是原理,这一节把它变成动作。核心思路:在链路的五个位置各放一个"我到过这里"的标记,用二分法缩小范围。

上电

① _start 第一条指令

② 钩子返回后

③ ind_crt0 入口

④ ind_crt1 入口

⑤ main 第一行

正常

图 3|五段定位。哪一段没到,问题就在上一段到这一段之间——不是"某处坏了",而是"区间缩小了"。

打点用什么?三个可选,按侵入性排序:

手段怎么做优点缺点
写魔数(推荐第一步)每个点往固定 RAM 地址写 0xA1/0xA2…不影响时序、不需要外设需要调试器看内存
翻 GPIO每个点翻转一个空闲脚用示波器能看时序和耗时需要空闲脚、需要初始化
死循环每个点后加 while(1)最原始但最确定一次只能测一个点,要重编 5 次

写魔数为什么最好用:它不改控制流、不依赖任何外设初始化(GPIO 可能正是坏的那部分),也不会因为"翻转太快示波器抓不到"而误判。选一个全程不会被覆盖的 RAM 地址(比如 RAM 最后 4 字节,但要确认不在栈范围内),在五个点各写不同值,上电后读一次就知道走到哪了。

读值 → 结论对照表:

读到的最后一个值卡在哪下一步查什么
什么都没写_start 之前入口点是否设错(第三节)、向量表/启动文件是否被链接
0xA1_start 到钩子返回之间__lowinit / board_memory_init 内部(时钟、SDRAM、ECC)
0xA2ind_crt0 入口之前设栈、FPU、picbase 计算
0xA3ind_crt0 内部段表(04 期的 SECTIONS)、拷贝源地址、CROM 解压
0xA4ind_crt1 内部某个弱钩子(__gh_iob_init 最常见)
0xA5main 第一行之前argc 处理、exit 之前

这张表的价值在于它把"跑飞"变成了"区间"。没有它,你只有"不跑"和"跑"两个信息;有了它,第一次上电就能砍掉 80% 的可能。

💡 阿栈点评:我建议每个新板子第一次 bring-up 都先放这五个点,哪怕程序是好的。因为它是"基线"——以后出问题时,你可以立刻回答"上次到这里是好的",而不是从零开始怀疑。这五行的成本,比任何调试器功能都低。

十二、跨架构对照:PowerPC 多核的启动多了什么

如果你在做 MPC57xx 这类多核 PowerPC,链路会长很多,因为多了三个裸机没有的问题:谁来启动、SRAM 的 ECC 怎么初始化、其他核怎么唤醒。

事项说明
Boot Header / RCHW从 Flash 起始特定地址读取,含 Boot Identifier = 0x5A。复位后决定复位配置、选择启动核、指定启动核的向量入口
SRAM ECC 初始化SRAM 使用前必须初始化,否则产生 ECC 错误。往所有 SRAM 位置写入任意值,必须写满整个总线宽度(64 位宽就做 64 位写)
MMU 初始化部分驱动需要
核寄存器初始化使用一致性(coherency)时,上电复位后核寄存器未初始化
其他初始化Mode Entry 模块、PLL、看门狗服务或关闭、Cache

以 MPC5746M 工程为例:

mpc5746m.ppc 中的 .boot_header 位于 0xF8C000(16KB Flash block 0)
  → 只使能一个核(CPU2)做初始引导
  → 引导核向量 __ghs_rombootcodestart = .text 起始
      → _start:
           bl __ghs_board_memory_init     ECC 初始化共享 SRAM
           bl __ghs_e200z4204_core_init   MSR[ME]=1、IVPR=0x40000000、
                                          初始化本地数据内存、初始化 I/D Cache
           GHS 内务:使能调试器 syscall(printf 靠它)、
                     初始化 SP/SDA/SDA2 到共享内存、PIC 相关处理
  → 跳转 __ghs_ind_crt0
      → 初始化静态和全局数据
      → PIC/PIR/PID 时重定位指针
      → 调用 __ghs_board_cache_sync(刷数据 cache、失效指令 cache)
      → 可选的已初始化数据解压缩、可选校验和

对照着看,你会发现 _start 的位置完全一样、__ghs_ind_crt0 的位置完全一样——多出来的只是前面"选核 + 清 ECC"和中间"核初始化"。这印证了本篇的核心观点:GHS 的启动链路是一套跨架构的骨架,架构差异被收敛在 crt0 一个文件里。

Boot Header 结构(MPC5746M):

字段值描述
.boot_header0x005A0001Boot header id + enable CPU2
_ghs_rombootcodestart—CPU2 复位地址
保留 ×20x0—
—0x0CPU0 / CPU1 / CPUC 复位地址
保留0x0—

⚠️ GHS 工程的 Boot Header 在 mpc5746m.ppc 文件里,不在 C 代码里。 这解释了高频困惑:“我明明给 CPU0 写了代码,为什么只有 CPU2 在跑?”——引导核是 Boot Header 选的,不是你在 main 里选的。 第 10 期展开。

十三、完整链路总图

main ind_crt1.c ind_crt0.c __lowinit / board_memory_init crt0 / startup*.850 复位向量 main ind_crt1.c ind_crt0.c __lowinit / board_memory_init crt0 / startup*.850 复位向量 取入口(_start / _start_T / -e 指定) 保存 argc/argv/envp 到 r6/r7/r8 bl 钩子(可能为空,静默跳过) 时钟 / 向量表 / ECC SYSCALL_HELLO 检测 MULTI 算 picbase,装 SP,开 FPU r0=baseptrs, r1..r3=参数 查 clear 表 → 清 BSS 查 copy 表 → ROM 搬到 RAM 查 comcopy 表 → CROM 解压 (*crt1p)(argc, argv, envp) 逐个 weak 钩子:board_devices / iob / malloc / lock argc==0 时构造 noname exit(main(argc, argv, envp))

图 2|完整启动链路。四个可以插手的点:crt0 的弱符号钩子、__lowinit(RH850)、ind_crt0 的段处理(靠链接脚本控制)、ind_crt1 的一串弱函数。

十四、避坑清单

现象原因处理
串口波特率全错、定时器周期不对__lowinit 没执行,时钟仍在默认源在 __lowinit 里翻 GPIO 验证;确认符号被链接
从 RAM 镜像启动,中断全飞__ghs_board_memory_init 未实现,VTOR 没设实现该钩子,str 向量表地址到 VTOR
全局变量初值随机自定义段没被清/拷(脚本缺 CLEAR 或没登记进表)检查 SECTIONS 是否给了正确属性
用了 CROM 但变量没初始化启动脚本不是 GHS 默认的,拷贝逻辑丢了保留 __ghs_ind_crt0 调用,或自己实现
所有中断都不响应向量表没被链进来用 __ghsautoimport_isr_vector 强制 import
调试器里 printf 有输出,脱机没有printf 依赖调试器 syscall 通道重定向到串口;发布版移除
PowerPC 上电就进异常SRAM 未做 ECC 初始化确认 __ghs_board_memory_init 被调用,且按总线宽度写满
多核只有 CPU2 在跑Boot Header 只使能了引导核检查 Boot Header 配置与从核启动代码
程序从地址 0 开始跑既无 _start 也无 start,走了兜底检查启动文件是否被链接、入口符号名
-e Reset_Handler 报冲突编译选项和 OPTION("-e ...") 同时写了二选一
程序一直在复位,进不了 main看门狗超时 < .bss 清零时间先放宽看门狗,再优化启动耗时(第十节)
清完 ECC 反而报 ECC 错误没按总线宽度写满用 strd/64 位写,别用 strb 循环
靠 argc == 0 判断"是不是脱机"ind_crt1 会把它改成 1改用其他判据
main 里写 return 后行为诡异走的是 exit → _Exit 那条路裸机 main 用 while(1) 收尾
栈分析算出的最大栈深虚高手写汇编钩子漏了 .scall __leaf__ / .maxstack补上声明
MINIMAL_STARTUP 被定义,部分初始化不执行有人为了省空间开了这个开关全局搜该宏,确认是否该开

十五、小结

  • 硬件先取址:RH850/V850 从 0x00000000 取复位入口;Cortex-M 是"地址 0 取 SP、地址 4 取复位向量",所以两份启动代码的"设栈"位置不同——是硬件差异不是风格差异;
  • 启动代码分两层:架构相关(汇编,基本不改)+ 架构无关(C,通过钩子扩展);ind_crt0.c 在 libstartup.a,ind_crt1.c 在 libsys.a;
  • 入口点有优先级:_start > start > _main > main > 零地址;Thumb-only 用 _start_T;-e 与 OPTION("-e ...") 二选一;
  • crt0 顶部那堆 .weak 是官方预留的钩子,__lowinit / __ghs_board_memory_init 都属于这类——没实现就静默跳过,这是"时钟不对但功能看似正常"的根因;
  • SYSCALL_HELLO 同时决定了"参数从哪来"和"栈从哪来",也是 printf 只在调试器下有输出的原因;
  • ind_crt0 是表驱动的,而且是三对表(clear/aclear、copy/tcopy、comcopy/comtcopy),结构都是"起点→分界→终点";记录是三元组,清 BSS 是 (目标, 值, 长),拷贝是 (目标, 源, 长);
  • PIC/PID/PIR 是三个基址:代码、数据、ROM 源。搞清"为什么需要基址",比记住名字有用;
  • ind_crt1 是一张弱函数清单,__ghs_board_devices_init() 是你"在 main 之前插代码"的正规入口;汇编钩子管"必须在 C 之前的事",C 钩子管普通外设;
  • argc 从 0 被改成 1 这条链跨了三个文件,别用 argc == 0 判断脱机;
  • main 是被 exit(main(...)) 调用的,裸机 main 要用 while(1) 收尾;
  • 自定义启动三种做法,官方只推荐第三种(重定义弱函数 / 调默认脚本),升级编译器不痛;
  • 启动耗时的大头是 .bss 清零,256KB 约 2ms(100MHz);看门狗超时比它短就会无限复位——这是"程序起不来"的一个非代码原因;
  • 跑飞用五段定位:写魔数、读值、按表查区间。成本极低,建议每个新板子 bring-up 都先做一遍。

下篇预告

下一篇讲中断。这是工程问题最集中的地方,也是 GHS 与 Keil/IAR 差异最大的地方——GHS 有三条完全不同的装表路径,选错一条,你会花一整天找不到中断为什么进不去。 而且本篇的 __ghsautoimport_isr_vector 和 __ghs_board_memory_init 会在那里再出现一次。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐