1. 先把账算清楚:为什么 HPM6750 要用 Ubuntu + CMake 这套组合
第一次把 HPM6750 这块板子插到 Ubuntu 主机上,多数人心里想的其实是"照着快速上手的文档跑一遍示例就行了"。但真正做过几个项目之后你会发现,HPM6750 属于那种"KEIL 里点两下能跑,想长期维护就一定要把构建体系掌握在自己手里"的芯片。它是一颗双核 32 位 RISC-V 的 MCU,主频跑得很高,片上 SRAM 容量大,但没有内置 Flash,程序要从外挂的 XPI NOR 里经过两级引导搬进 SRAM 执行。这个架构特点直接决定了它的开发流程跟传统单片机不一样:你得关心镜像头、链接脚本里的 flash 段和 ram 段、启动文件里的向量表位置,以及烧录工具到底把二进制写到了哪个地址。这些东西在图形化 IDE 里是被隐藏起来的,一旦出问题就只能干瞪眼。所以这篇内容讲的是我在 Ubuntu 上从零搭 RISC-V 交叉编译环境、用 CMake 把这颗芯片的构建流程完整跑通的全过程,包括工具链怎么选、toolchain.cmake 怎么写、链接脚本怎么绑、烧录和调试怎么串起来。适合已经有一点嵌入式基础、想把开发流程做得更工程化的朋友,也适合刚从 ARM 平台转过来、被 RISC-V 工具链搞得有点懵的人。
1.1 芯片架构决定了构建体系的形状
HPM6750 的几个参数会直接影响你的工程结构,我先把它们摆出来。第一是双核 RISC-V,两个核各自有本地紧耦合的 ILM 和 DLM,容量不算小,系统层面还有一块共享 SRAM。第二是片上无 Flash,代码常驻在外部 NOR 里,上电后由芯片内部固化的引导程序读取镜像头、校验、搬运。第三是主频高、外设全,以太网、CAN FD、USB、高精度 ADC、多路 PWM 都有,这意味着实际项目里中断向量、DMA 缓冲区、非缓存区的安排会很讲究。
这三条拼在一起,就能解释为什么很多人在第一步就卡住:直接拿一个裸的main.c编译出来的 elf,烧进去不跑。原因不是你代码错了,而是镜像里缺少引导程序认识的启动头,或者链接地址落在了一个引导程序根本不会搬的区间。我第一次踩这个坑的时候,串口一点输出都没有,以为是串口配置问题,查了两个小时才发现是链接脚本用错了版本。所以后面第 3 节我会把链接脚本和启动文件的绑定关系单独拎出来讲,这部分是这套流程里最容易出错、也最值得一次性做对的地方。
1.2 为什么不用 IDE,而要坚持命令行构建
先说清楚,我不是反对 IDE。临时验证一个外设寄存器,用 IDE 点一下确实快。但项目一旦超过两个人、超过三个月,IDE 的隐式配置就会变成灾难:编译选项藏在某个.cproject里,链接脚本路径是绝对路径,换台机器就打不开,代码评审的时候没人能看出你到底改了哪个宏。而 CMake 的好处是所有构建逻辑都是纯文本,可以进版本库、可以被 review、可以在 CI 上跑。
具体到 HPM6750 这个场景,还有两个额外理由。一是多配置切换频繁:调试版要带-Og -g3,发布版要-O2甚至-Os,有时候还要把代码放到 ILM 里跑以获得确定性延迟,有时候要放到外部存储里跑以腾出空间。这些在 IDE 里是几套工程文件,在 CMake 里就是几个-DCMAKE_BUILD_TYPE或者几个自定义的 cache 变量,切换成本几乎为零。二是产物后处理链路长:编译完要生成.bin、要检查 size、要生成带启动头的镜像、要调烧录工具,这一串用add_custom_command串起来,cmake --build一条命令全干完,比手动点五下靠谱得多。
1.3 工具链和 ABI 必须一次选对
这是最容易埋雷的地方。裸机 RISC-V 用的工具链是riscv32-unknown-elf-这一套,也就是带 newlib 的嵌入式 ELF 工具链,而不是riscv64-unknown-linux-gnu-那种面向 Linux 应用的。两者都能编出 RISC-V 机器码,但后者链接的是 glibc,还有一堆系统调用依赖,裸机环境下会链接失败或者跑飞。
第二个坑是 ABI。HPM6750 带硬件浮点,编译选项通常是-march=rv32imafdc -mabi=ilp32d,其中ilp32d表示参数通过双精度浮点寄存器传递。如果你的应用代码用ilp32d、而预编译的库用ilp32f或者软浮点,链接阶段就会报出类似 "can't link double-float modules with soft-float modules" 的错误。这个错误信息其实很明确,但新手容易慌,以为工具链装坏了。解决办法只有一个:全工程统一 ABI,包括你自己编的库、第三方编的库、启动文件,一个都不能例外。我在第 2 节会用一条命令帮你验证工具链到底支不支持你想要的架构组合。
2. Ubuntu 侧的准备:从空系统到能跑通一次编译
我用的是一台普通的 x86_64 机器跑 Ubuntu 22.04 LTS,20.04 和 24.04 也都试过,差别不大,主要是 CMake 版本和默认 Python 版本的区别。这一节按顺序把依赖、工具链、调试器、串口权限、SDK 目录五件事做完,做完之后你应该能顺利编出一个.elf,哪怕它还没法烧进去跑。
2.1 基础依赖与目录规划
先装系统包。这里面有几个容易被漏掉的:ninja-build是可选的但强烈建议装,构建速度比 make 快不少;libusb-1.0-0-dev和libftdi1-dev是后面自己编 OpenOCD 要用的;python3是因为厂商 SDK 里有些脚本用它做配置生成;device-tree-compiler在需要处理设备树或者生成某些配置文件时会用到。
sudo apt update sudo apt install -y build-essential cmake ninja-build git \ libusb-1.0-0-dev libftdi1-dev pkg-config \ python3 python3-pip device-tree-compiler \ minicom picocom gdb-multiarch目录规划我习惯这样分,好处是卸载和升级都很干净,不会污染系统路径:
mkdir -p ~/work/hpm6750 # 工具链解压到 ~/work/hpm6750/toolchain # SDK 放在 ~/work/hpm6750/sdk # 自己的工程放在 ~/work/hpm6750/projects注意:不要把工具链和 SDK 放进
/usr/local里。以后想同时保留两个版本的 SDK 做对比,或者想验证新版本工具链有没有回归问题时,放在用户目录下切换环境变量就行,不用动系统。
2.2 RISC-V 工具链安装与校验
工具链从芯片厂商的发布页或者开源社区的 RISC-V GNU 工具链发布页拿,选linux-x86_64的压缩包,解压后把bin目录加到PATH里。我更推荐写进~/.bashrc里,并且用一个变量指代路径,这样换版本只改一行:
export HPM_TOOLCHAIN=~/work/hpm6750/toolchain/riscv32-unknown-elf export PATH=$HPM_TOOLCHAIN/bin:$PATH装完必须验证三件事,缺一不可。第一是版本,第二是它到底支持哪些架构扩展,第三是能不能真的编出目标文件。前两件事一条命令就能看出来:
riscv32-unknown-elf-gcc --version riscv32-unknown-elf-gcc -Q --help=target | grep -E "march|mabi"更靠谱的做法是直接用目标参数试编一个空文件,编译阶段能过、汇编阶段能过,基本就说明这套组合是被支持的:
echo 'int dummy(void){return 0;}' > /tmp/t.c riscv32-unknown-elf-gcc -march=rv32imafdc -mabi=ilp32d -mfloat-abi=hard \ -c /tmp/t.c -o /tmp/t.o && echo "ABI OK" riscv32-unknown-elf-readelf -h /tmp/t.o | grep -E "Flags|Machine"readelf输出的Flags里会带RVC和double-float ABI之类的字样,这是最直接的证据。如果你看到的是soft-float,说明这条工具链的多库配置里没有ilp32d,那就得换一个版本,别硬扛。
提示:工具链版本不必盲目追新。老一些的版本社区验证更充分,新版本可能在
-Os的代码生成上有细微变化,导致某些对时序极敏感的驱动出问题。我的建议是团队内部锁定一个版本号,把压缩包的校验和写进文档,所有人用同一个。
2.3 调试器、串口与权限配置
HPM6750 的评估板一般板载调试器,走的是 FT2232 或者 CMSIS-DAP 方案。Ubuntu 下默认只有 root 能访问 USB 设备,普通用户跑 OpenOCD 会报权限错误。标准的解决办法是加 udev 规则:
sudo tee /etc/udev/rules.d/99-hpm-debug.rules > /dev/null <<'EOF' # FTDI FT2232 SUBSYSTEM=="usb", ATTR{idVendor}=="0403", MODE="0666", GROUP="plugdev" # CMSIS-DAP 常见 VID SUBSYSTEM=="usb", ATTR{idVendor}=="0d28", MODE="0666", GROUP="plugdev" SUBSYSTEM=="usb", ATTR{idVendor}=="1209", MODE="0666", GROUP="plugdev" EOF sudo udevadm control --reload-rules && sudo udevadm trigger串口权限也要处理,把自己的用户加进dialout组,然后必须重新登录才生效,这一点很多人会忘:
sudo usermod -aG dialout $USER # 退出登录再进来,然后验证 ls -l /dev/ttyUSB*OpenOCD 有两条路:用 SDK 里带的预编译版本,或者自己从源码编。自己编的好处是版本可控,配置文件路径清晰;坏处是第一次编要处理依赖。我一般是自己编,./configure --enable-ftdi --enable-cmsis-dap --prefix=$HOME/work/hpm6750/openocd,然后make -j$(nproc) && make install,最后把bin加进PATH。编完跑一次openocd --version确认。
2.4 把 SDK 的目录结构摸清楚
拿到 SDK 之后别急着编示例,先花十分钟看目录。通常长这样:arch/放 CPU 架构相关的启动汇编和向量表;soc/放芯片级的外设基地址、时钟树、中断号定义;drivers/是外设驱动;middleware/是协议栈、文件系统之类;boards/是各评估板的引脚和时钟配置;samples/是示例工程;cmake/是厂商提供的 CMake 辅助脚本,这一块最值得读。
我特别建议你去samples/里随便挑一个最简单的工程,把它的CMakeLists.txt从头到尾读一遍。你会发现厂商已经封装了一些宏,比如设置编译选项、添加源文件、配置链接脚本、生成镜像头。读懂了这些,你自己写工程的时候就是在它们的基础上做减法,而不是从空白开始猜。
3. CMake 工程骨架:一份可以直接抄的构建脚本
这一节是整篇的核心。我会给出一个最小可用的工程结构,把最关键的几个文件说清楚,每个选项为什么这么写都会解释。
3.1 顶层 CMakeLists.txt 的层次划分
我的习惯是把顶层文件控制得很薄,只做三件事:声明最低 CMake 版本、引入工具链设置、把子目录加进来。具体逻辑全放到子目录里,这样以后加第二个可执行目标、加单元测试都很方便。
cmake_minimum_required(VERSION 3.20) project(hpm6750_demo LANGUAGES C ASM) # 全局策略:禁止在源码目录内构建 if(CMAKE_SOURCE_DIR STREQUAL CMAKE_BINARY_DIR) message(FATAL_ERROR "In-source build is not allowed. Use -B build.") endif() set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # SDK 路径通过 cache 变量传入 set(HPM_SDK_BASE "${CMAKE_CURRENT_SOURCE_DIR}/../sdk" CACHE PATH "hpm sdk root") if(NOT EXISTS "${HPM_SDK_BASE}/cmake") message(FATAL_ERROR "HPM_SDK_BASE not valid: ${HPM_SDK_BASE}") endif() add_subdirectory(app)CMAKE_EXPORT_COMPILE_COMMANDS ON这行值得单独说。打开之后会生成compile_commands.json,配合编辑器或者语言服务器,代码补全和跳转能准确到每个文件的每一个宏定义,写驱动的时候体验差距非常大。这是我坚持用 CMake 的理由里最实在的一条。
3.2 toolchain.cmake 里最关键的那几行
工具链文件是整套构建的地基,它必须在project()之前生效,所以要用-DCMAKE_TOOLCHAIN_FILE传进去,而不是在CMakeLists.txt里include。
set(CMAKE_SYSTEM_NAME Generic) set(CMAKE_SYSTEM_PROCESSOR riscv32) set(TOOLCHAIN_PREFIX riscv32-unknown-elf-) set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}gcc) set(CMAKE_ASM_COMPILER ${TOOLCHAIN_PREFIX}gcc) set(CMAKE_AR ${TOOLCHAIN_PREFIX}ar) set(CMAKE_OBJCOPY ${TOOLCHAIN_PREFIX}objcopy) set(CMAKE_OBJDUMP ${TOOLCHAIN_PREFIX}objdump) set(CMAKE_SIZE ${TOOLCHAIN_PREFIX}size) # 关键:裸机环境没有操作系统的启动文件,CMake 的编译器探测会链接失败 set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) set(ARCH_FLAGS "-march=rv32imafdc -mabi=ilp32d -mcmodel=medlow") set(CMAKE_C_FLAGS_INIT "${ARCH_FLAGS} -ffunction-sections -fdata-sections -fno-common") set(CMAKE_ASM_FLAGS_INIT "${ARCH_FLAGS}") set(CMAKE_EXE_LINKER_FLAGS_INIT "${ARCH_FLAGS} -nostartfiles -Wl,--gc-sections -Wl,--print-memory-usage") set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)逐条解释。CMAKE_SYSTEM_NAME Generic告诉 CMake 这是裸机环境,不要去找 Linux 的隐式规则。CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY是踩坑踩出来的经验:CMake 在配置阶段会做编译器能力探测,默认是编一个可执行文件并链接,而裸机工程这个时候还没有链接脚本和启动文件,链接必然失败,然后 CMake 就报"编译器无法编译简单程序",你换十次工具链也没用。把探测方式改成静态库,只编译不链接,问题就消失了。
-mcmodel=medlow在小容量地址空间里生成的代码更紧凑,访问全局变量用绝对地址,速度快。如果以后要把代码放到高地址的外部存储里,可能需要换成medany,那是另一个话题。-ffunction-sections -fdata-sections配合链接器的--gc-sections,能把没用到的函数整段裁掉,对于 SRAM 紧张的工程是实打实的收益。
3.3 链接脚本、启动文件和启动头
这三个东西是配套的,缺一不可。启动文件是汇编,负责设置栈指针、初始化gp、清.bss、拷贝.data,最后跳到main。链接脚本决定每一段放在哪块存储里。启动头是引导程序识别镜像的凭证,一般是一个固定格式的小结构体,放在镜像最前面。
# app/CMakeLists.txt 节选 set(LINKER_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/linker_and_flash.ld") add_executable(${PROJECT_NAME}.elf src/main.c src/start.S ${HPM_SDK_BASE}/soc/startup/system_reset.c ) target_include_directories(${PROJECT_NAME}.elf PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/inc ${HPM_SDK_BASE}/soc ${HPM_SDK_BASE}/drivers ) target_link_libraries(${PROJECT_NAME}.elf PRIVATE -T${LINKER_SCRIPT} -Wl,-Map=${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}.map -Wl,--defsym=__flash_size=16M c nosys ) target_link_options(${PROJECT_NAME}.elf PRIVATE --specs=nano.specs --specs=nosys.specs )--specs=nano.specs换成 newlib-nano,能省下不少代码空间,代价是printf的浮点格式化默认不可用。这个取舍很实在:如果你确实需要在串口里打印浮点,要么自己写格式化,要么在链接选项里加-u _printf_float,后者会让镜像大几 KB。我一般前期调试打开,发布时关掉。
--specs=nosys.specs提供一堆系统调用的空实现,比如_write、_sbrk。裸机环境下标准库的有些函数需要这些桩函数才能链接通过。你想让printf输出到串口,就自己实现_write,在里面调串口发送就可以了。
关于启动头,不同 SDK 的做法不一样,有的是在链接脚本里单独开一个段,有的是用一个独立的 C 文件定义结构体并放到指定段。核心思路是一致的:保证这个结构体在镜像的最前面,并且里面的字段(入口地址、镜像长度、校验值、版本)填对。这块建议直接沿用 SDK 示例里的做法,不要自己发明,因为引导程序是固化在芯片里的,它只认一种格式。
注意:链接脚本里的内存区域定义要以具体型号的手册为准。ILM、DLM、系统 SRAM 的起始地址和容量,不同型号是不一样的。我见过有人直接从别的型号抄链接脚本过来,编译一切正常,烧进去就是不跑,查了一整天才发现
.data被放到了一个不存在的地址上,写入直接丢失。
3.4 编译后的产物处理与体积检查
编完只是第一步,后面还有一堆后处理。我的习惯是全部挂到add_custom_command上,让一条cmake --build build干完所有事。
add_custom_command(TARGET ${PROJECT_NAME}.elf POST_BUILD COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${PROJECT_NAME}.elf> ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}.bin COMMAND ${CMAKE_OBJCOPY} -O ihex $<TARGET_FILE:${PROJECT_NAME}.elf> ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}.hex COMMAND ${CMAKE_SIZE} -A -x $<TARGET_FILE:${PROJECT_NAME}.elf> COMMENT "Generating bin/hex and reporting size" )size -A -x会把每一段的大小列出来,比默认的text/data/bss三行信息量大得多。工程一大,你就需要看看到底是哪个段在膨胀。我还习惯在 CI 里加一条规则:镜像大小超过阈值就报错,防止某次提交不小心把一个大数组塞进去把 SRAM 撑爆。这种事发生过一次之后,你就会觉得这个检查很有必要。
4. 从编译到点亮:一次完整的落地流程
理论讲完,走一遍真实流程。我用一个最典型的场景:新建工程、编译、烧录、串口打印、断点调试。
4.1 新建最小工程并完成第一次构建
目录结构我这样放:
projects/blink/ ├── CMakeLists.txt ├── app/ │ ├── CMakeLists.txt │ ├── inc/ │ ├── src/main.c │ ├── src/start.S │ └── linker_and_flash.ld └── cmake/toolchain.cmake配置和构建的命令很固定,建议写成脚本,因为参数长:
cmake -S . -B build -G Ninja \ -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain.cmake \ -DCMAKE_BUILD_TYPE=Debug \ -DHPM_SDK_BASE=$HOME/work/hpm6750/sdk cmake --build build -j$(nproc)CMAKE_BUILD_TYPE=Debug对应的选项我在app/CMakeLists.txt里显式定义,不依赖 CMake 的默认值,因为默认值在嵌入式场景下往往不合适:
set(CMAKE_C_FLAGS_DEBUG "-Og -g3 -gdwarf-4") set(CMAKE_C_FLAGS_RELEASE "-O2 -DNDEBUG") set(CMAKE_C_FLAGS_RELWITHDEBINFO "-O2 -g3")用-Og而不是-O0,是因为-O0生成的代码太啰嗦,单步调试时跳来跳去,反而更难跟。-gdwarf-4是给调试器兼容性上的保险,有些版本的 GDB 对 DWARF 5 支持不完整,断点信息会显示异常。
main.c里第一版我建议只做两件事:初始化串口、打印一行字符串,然后进主循环翻转一个 GPIO。不要一上来就调以太网或者 USB,变量太多,出问题没法定位。
4.2 烧录进外部 NOR 并验证启动
烧录有几种方式,我按可靠性排序。第一种是走调试器用 OpenOCD 直接写外部 NOR,最稳,也最容易自动化:
openocd -f interface/ftdi/hpm-ft2232.cfg \ -f board/hpm6750-board.cfg \ -c "program build/blink.elf verify reset exit"第二种是用厂商提供的命令行烧录工具,通常是烧.bin并指定地址,适合产线或者需要批量的时候用。第三种是芯片内置的 USB ISP 方式,通过 USB 直接进引导模式烧写,不依赖调试器,但需要手动拨开关进模式,日常开发用起来慢。
烧完以后验证的顺序很重要,别一上来就指望看到串口输出。正确的顺序是:先确认 OpenOCD 能连上、能读到芯片 ID;再确认烧录过程 verify 通过;然后复位,用调试器读一下 SRAM 里的几个关键地址,看看向量表和启动头是不是你预期的内容;最后才看串口。这个顺序能帮你快速区分是"没烧进去"还是"烧进去了但跑飞了"。
4.3 OpenOCD + GDB 的调试会话
调试流程是两窗口。一个窗口跑 OpenOCD 起服务:
openocd -f interface/ftdi/hpm-ft2232.cfg \ -f board/hpm6750-board.cfg \ -c "adapter speed 4000"另一个窗口用 GDB 连上去:
gdb-multiarch build/blink.elf (gdb) target remote :3333 (gdb) monitor reset halt (gdb) load (gdb) break main (gdb) continuemonitor开头的命令是直接发给 OpenOCD 的,monitor reset halt是复位并停住,这一步很关键,因为芯片可能在你还来不及下断点的时候就已经跑过去了。load会把 elf 写进目标存储,适合快速迭代,比每次都走完整的烧录流程快得多。
在 GDB 里几个常用的检查手段:info registers看寄存器,x/16xw 0x...看内存,p/x $pc确认当前执行位置,bt看调用栈。如果bt显示??,多半是栈被踩了或者启动文件里没正确设置栈指针。
4.4 双核启动与内存布局的几个注意点
HPM6750 是双核,第二个核默认是停在复位状态的,需要由主核去释放。常见做法是主核把从核的入口地址写到一个约定的寄存器或者共享内存位置,然后触发从核启动。这部分代码建议照抄 SDK 里的示例,因为涉及芯片内部的启动控制寄存器,自己摸索容易出错。
真正需要注意的是内存。两个核各自有本地 ILM/DLM,访问本地存储延迟低、确定性好,适合放中断服务程序和实时性要求高的代码;共享 SRAM 两个核都能访问,但共享区要考虑缓存一致性。如果主核写了数据放在共享区,从核去读,而你用的区是可缓存的,从核可能读到旧值。解决办法通常是把这个区配置成非缓存,或者在写完数据后做一次缓存写回和屏障操作。这类问题最难查,因为现象是偶发的、跟时序相关的,加个打印就消失了。
我在实际项目里的做法是:给共享数据结构单独开一个段,链接脚本里把它放到非缓存区域,两个核都用volatile指针访问,并且约定好谁写谁读的方向。这个约定写进头文件的注释里,比事后查 bug 便宜太多。
5. 踩坑实录:四类问题的排查路径
下面这些是我和周围人真实遇到过的,按类别整理,附上排查思路。表格只是速查,真正的排查逻辑在后面几段。
5.1 编译与工具链类问题速查
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
cannot find -lc | 工具链路径没进 PATH,或前缀写错 | 检查CMAKE_C_COMPILER实际指向 |
can't link double-float modules with soft-float modules | ABI 不统一 | 全工程统一-mabi=ilp32d,重编所有库 |
| CMake 报编译器无法编译简单程序 | try_compile 链接失败 | 设CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY |
undefined reference to _write | 没提供系统调用桩 | 实现_write或加--specs=nosys.specs |
| 汇编文件报未知指令 | -march没开对应扩展 | 检查是否漏了c或f/d |
关于 ABI 这一条再补充一句。很多人是在工程里加了第三方库之后才出现这个错误,第一反应是库有问题。其实是你自己的工程用ilp32d,那个库是用ilp32f编的,链接器发现两边对浮点参数的传递约定不一致,就拒绝了。这不是 bug,是保护机制。解决办法是要么拿到源码重编,要么改用一致的 ABI 配置。
5.2 链接与内存类问题速查
| 现象 | 排查方向 | 说明 |
|---|---|---|
region RAM overflowed | 段大小超过链接脚本定义 | 看 map 文件里哪个段最大 |
| 编译通过但烧录后不运行 | 启动头缺失或位置不对 | 检查镜像最前面的字节 |
| 全局变量初值不对 | .data拷贝没做或源地址错 | 查启动文件里的拷贝循环 |
| 偶发数据错乱 | 缓存一致性或栈溢出 | 检查栈大小和非缓存区配置 |
| 断点打不上 | 优化等级过高或调试信息缺失 | 临时降到-Og,确认-g3 |
.data初值这个问题值得展开。全局变量int a = 5;的初值 5 是存在镜像里的,启动时要把这段数据从存储搬到 RAM,然后清空.bss。如果你的链接脚本里.data的加载地址和运行地址没写清楚,这个拷贝就会搬错地方,现象是变量初值是随机的。查这个最快的办法是在main的第一行打个断点,看那个变量的值;如果不等于初值,就基本锁定了。
5.3 烧录与调试类问题速查
| 现象 | 排查方向 |
|---|---|
| OpenOCD 报找不到设备 | udev 规则、USB 线、调试器供电 |
| 烧录 verify 失败 | 外部 NOR 型号不匹配、时钟配置错 |
| 能连上但读不出 ID | 复位状态、调试接口被禁用 |
| 单步时程序跑飞 | 中断打断、看门狗复位 |
| 断点命中但变量显示优化掉 | 用volatile或降优化等级 |
"能连上但读不出 ID"这条我印象很深。有一次换了块新板子,OpenOCD 一路报错,试了各种配置都不行,最后发现是板上调试接口的供电跳线没接,芯片根本没上电。所以排查顺序永远是:先确认物理连接和供电,再看软件配置。这个顺序能省掉大量无效尝试。
5.4 上电不跑、跑飞、进异常的处理顺序
我的固定顺序是这样的。第一步,确认烧录是真的成功了,用调试器读回镜像头的前若干个字节,跟你本地的 bin 文件对比。第二步,复位后立刻停住,看 PC 指向哪里,如果指向的是一个莫名其妙的地方,说明向量表没配对。第三步,单步跟启动汇编,看栈指针设置是否合理,gp有没有初始化,.bss清零循环有没有正常结束。第四步,如果能进main但很快就跑飞,八成是中断触发了但向量表里没有对应的处理函数,或者栈溢出了。
栈大小这个事很多人不重视。默认链接脚本给的栈可能只有几 KB,一个带printf的函数加上中断嵌套,很容易就踩穿。我的做法是在链接脚本里把栈放到一个单独的段,边界填上魔法值,运行一段时间后检查魔法值有没有被改写,被改写就说明栈用超了。这个技巧成本极低,但能提前发现一类很难复现的偶发故障。
6. 我在把这条流水线跑顺之后的几点体会
这套环境我从头搭过几次,每次都会发现一些可以更省事的地方,说几个最实用的。
第一是把工具链、SDK、OpenOCD 的版本都写进一个env.sh,进项目先source一下,所有路径和版本都锁死。团队里每个人环境一致,能消掉大量"我这里能编你那里不能编"的扯皮。这个文件不需要复杂,十几个export就够了。
第二是把编译选项的验证做成一次性的测试。我在cmake/下放了一个小脚本,配置阶段就跑一次,检查工具链能否用目标 ABI 编译并通过readelf验证。跑不过就直接报错退出,比编到一半才发现问题友好得多。
第三是不要迷信-O2。有些驱动在-O2下会出问题,不是编译器有 bug,而是代码里少了volatile或者缺少内存屏障。遇到"Debug 版正常、Release 版异常",先怀疑自己的代码,别急着换优化等级绕过,绕过的问题下次还会回来。
第四是map 文件要经常看。尤其是项目做到中后期,SRAM 快满的时候,map 文件里能看出哪个库被整个链了进来、哪个函数特别大。我靠这个找出过一次链接了一个根本没用到的加密库,占了三十多 KB,去掉之后空间一下就宽松了。养成每次发版前扫一眼 map 的习惯,成本几秒钟,收益很高。