diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml index 6789d7d..59d2500 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-check.yml @@ -1,10 +1,33 @@ name: Build Check on: - pull_request: + push: branches: [main] + pull_request: + workflow_dispatch: jobs: + verify: + name: Verify (journey replays) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install cross toolchain & emulator + run: sudo apt-get update && sudo apt-get install -y gcc-arm-none-eabi qemu-system-arm gdb gdb-multiarch + + - name: Lint (commands vs scripts) + shell: bash + env: + LINT_STRICT: '1' + run: bash scripts/lint.sh + + - name: Replay all journey scripts + shell: bash + run: bash scripts/run-all.sh + build: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index b6c5631..2ed3a48 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ site/.vitepress/.build-tmp/ *.tgz .pnpm-store/ +build/ \ No newline at end of file diff --git a/assets/brand/embedbox-mark-green.png b/assets/brand/embedbox-mark-green.png new file mode 100644 index 0000000..86c7fc0 Binary files /dev/null and b/assets/brand/embedbox-mark-green.png differ diff --git a/assets/brand/embedbox-mark-inverse.png b/assets/brand/embedbox-mark-inverse.png new file mode 100644 index 0000000..bd4b402 Binary files /dev/null and b/assets/brand/embedbox-mark-inverse.png differ diff --git a/examples/instructions.md b/examples/instructions.md deleted file mode 100644 index 7829ab8..0000000 --- a/examples/instructions.md +++ /dev/null @@ -1,16 +0,0 @@ -# examples — 配套资产说明 - -本目录存放教程正文中引用的配套资产:示例代码、硬件电路图、PCB 文件、数据手册片段等。 - -## 命名约定 - -- 一个教程章节对应的资产,放在以该章命名的子目录里(如 `git/`、`wsl/`)。 -- 文件名用英文小写 + 连字符(`kebab-case`),避免中文 / 空格——与站点「目录全英文」约定一致。 -- 代码文件带语言后缀(`.c`、`.py`、`.dts`);图片用 `.png` / `.svg`。 - -## 与教程正文的关系 - -- 正文 Markdown 在 `tutorial/`,通过相对路径引用本目录的资产。 -- 本目录的文件**不**进 VitePress 的 `srcDir`(不会被渲染成页面),只作为被引用的静态资源。 - -> 本目录原名 `codes_and_assets/`,已改名 `examples/` 并修正文件名拼写(原 `instractions.md` → `instructions.md`)。 diff --git a/project.config.ts b/project.config.ts index 6982971..8776cdd 100644 --- a/project.config.ts +++ b/project.config.ts @@ -51,5 +51,5 @@ export default defineProject({ mermaid: true, }, - favicon: '/EmbedBox/Awesome-Embedded.ico', + favicon: '/EmbedBox/embedbox-mark.svg', }) diff --git a/scripts/journey/00-env-check.sh b/scripts/journey/00-env-check.sh new file mode 100755 index 0000000..7c803ae --- /dev/null +++ b/scripts/journey/00-env-check.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# ── 第 1 个历程 · 环境体检 ──────────────────────────────────────────── +# 本历程比较特殊:验证脚本本身就是教具——教程教你读懂它输出的体检报告。 +# 必需工具(git/gcc/make)缺失 → 红;建议工具缺失只提示,不算失败。 +# tier: ci-matrix +# +# 本脚本的运行方式:./scripts/journey/00-env-check.sh +# +# 正文示例命令(逐字收录,供 lint 对账;脚本自己不会去装): +# sudo apt install git +# +# 装齐工具的手工指引(不同平台各取所需;脚本自己不会去装): +# Ubuntu/Debian: sudo apt install build-essential gdb cmake +# Arch: sudo pacman -S base-devel gdb cmake +# Windows 先装 WSL2(PowerShell 管理员): +# wsl --install -d Ubuntu +# 交叉工具链与模拟器(第 6/7 个历程 才需要,现在可以先不装): +# Ubuntu/Debian: sudo apt install gcc-arm-none-eabi qemu-system-arm +# Arch: sudo pacman -S arm-none-eabi-gcc qemu-arm +set -euo pipefail + +banner() { printf '\n──────── %s ────────\n' "$*"; } + +banner "机器与系统" +uname -a +printf 'bash %s\n' "${BASH_VERSION:-?}" + +banner "必需工具(缺任何一个,体检就是红)" +missing=0 +for tool in git gcc make; do + if command -v "$tool" >/dev/null 2>&1; then + printf ' [ok] %-8s -> %s\n' "$tool" "$(command -v "$tool")" + else + printf ' [缺失] %s\n' "$tool" + missing=1 + fi +done +[ "$missing" -eq 0 ] || { echo "FATAL: 必需工具缺失,安装指引见本文件头部注释" >&2; exit 1; } + +banner "工具版本" +git --version +gcc --version | head -1 +make --version | head -1 + +banner "建议工具(现在缺不要紧,后面的历程会用到再装)" +for tool in gdb cmake arm-none-eabi-gcc qemu-system-arm; do + if command -v "$tool" >/dev/null 2>&1; then + printf ' [ok] %s\n' "$tool" + else + printf ' [未装] %s —— 后面的历程会用到,到时候再装也来得及\n' "$tool" + fi +done + +banner "PATH —— shell 找命令的地方" +echo "$PATH" | tr ':' '\n' | sed 's/^/ /' + +banner "command not found 的三板斧" +echo " 1) 它装了吗: which gcc" +echo " 2) 它在哪: echo \"\$PATH\" 里有没有那个目录" +echo " 3) 名字对吗: 拼写、大小写、别名" + +echo +echo "✅ 第 1 个历程 · 环境体检 —— 必需项全部就绪" diff --git a/scripts/journey/01-elf.sh b/scripts/journey/01-elf.sh new file mode 100755 index 0000000..6a83a01 --- /dev/null +++ b/scripts/journey/01-elf.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# ── 第 2 个历程 · 源码→程序 ─────────────────────────────────────────── +# 重放 tutorial/journey/01-elf.md 的全部命令:把一行 hello.c +# 逐步变成可执行文件,并把每一段的中间产物拆开看。 +# tier: ci-matrix +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SRC="$REPO_ROOT/src/journey/01-elf" + +command -v gcc >/dev/null 2>&1 || { echo "FATAL: 没有 gcc,请先完成第 1 个历程 体检" >&2; exit 1; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +cd "$WORK" +cp "$SRC/hello.c" . + +banner() { printf '\n──────── %s ────────\n' "$*"; } + +# ═══ 开场:一行命令,先跑起来 ═══ +banner "一行命令,先跑起来" +gcc hello.c -o hello && ./hello +./hello | grep -q 'hello, EmbedBox!' || { echo "FATAL: 输出不符合预期" >&2; exit 1; } + +# ═══ 第一步:只跑预处理器 ═══ +banner "gcc -E:只跑预处理器" +gcc -E hello.c -o hello.i +wc -l hello.i +head -12 hello.i + +# 断言:stdio.h 展开后体积暴涨(几百行起步) +lines="$(wc -l < hello.i)" +[ "$lines" -gt 100 ] || { echo "FATAL: 预处理产物行数异常: $lines" >&2; exit 1; } + +# ═══ 第二步:到汇编为止 ═══ +banner "gcc -S:到汇编为止" +gcc -S hello.c +grep -n 'main:' hello.s +sed -n '/^main:/,/ret/p' hello.s + +# ═══ 第三步:到目标文件为止 ═══ +banner "gcc -c:到目标文件为止" +gcc -c hello.c +objdump -f hello.o + +# 断言:目标文件带着「待重定位」标记,还不是可执行文件 +objdump -f hello.o | grep -q 'HAS_RELOC' \ + || { echo "FATAL: hello.o 应当是可重定位目标文件" >&2; exit 1; } + +nm hello.o + +# 断言:符号表里有已定义的 main 和未决的标准库函数 +# (gcc 会把单参数带换行的 printf 优化成 puts,两者都合法) +nm hello.o | grep -q ' T main' || { echo "FATAL: 符号表缺 main" >&2; exit 1; } +nm hello.o | grep -Eq ' U (printf|puts)' || { echo "FATAL: 符号表缺未决的 printf/puts" >&2; exit 1; } + +# ═══ 第四步:链接成可执行文件 ═══ +banner "链接:hello.o → hello" +gcc hello.o -o hello +objdump -f hello +nm hello | grep -q ' T main' || { echo "FATAL: 可执行文件缺 main" >&2; exit 1; } +objdump -f hello | grep -q 'x86-64' || { echo "FATAL: 架构字段异常" >&2; exit 1; } +./hello +./hello | grep -q 'hello, EmbedBox!' || { echo "FATAL: 输出不符合预期" >&2; exit 1; } + +# ═══ 拆开看:段、反汇编、体积 ═══ +banner "readelf -S:可执行文件里的段" +readelf -S hello | grep -E '(\.text|\.data|\.bss|\.rodata)' || true +readelf -S hello | grep -q '\.text' || { echo "FATAL: 缺 .text 段" >&2; exit 1; } +readelf -S hello | grep -q '\.bss' || { echo "FATAL: 缺 .bss 段" >&2; exit 1; } + +banner "objdump -d:看自己程序的汇编" +objdump -d hello | sed -n '/
:/,/^$/p' + +banner "size:三个段各占多少" +size hello + +echo +echo "✅ 第 2 个历程 · 源码→程序 —— 全部断言通过" diff --git a/scripts/journey/02-gdb.sh b/scripts/journey/02-gdb.sh new file mode 100755 index 0000000..95d3d35 --- /dev/null +++ b/scripts/journey/02-gdb.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# ── 第 3 个历程 · 程序病了 ──────────────────────────────────────────── +# 重放 tutorial/journey/02-gdb.md 的全部命令:用 gdb 脚本化会话 +# 揪出 buggy.c 里故意埋的越界读。 +# tier: ci-matrix +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SRC="$REPO_ROOT/src/journey/02-gdb" + +command -v gcc >/dev/null 2>&1 || { echo "FATAL: 没有 gcc" >&2; exit 1; } +command -v gdb >/dev/null 2>&1 || { echo "FATAL: 没有 gdb,请回第 1 个历程 补装" >&2; exit 1; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +cd "$WORK" +cp "$SRC/buggy.c" . + +banner() { printf '\n──────── %s ────────\n' "$*"; } + +# ═══ 编译带调试信息的版本,先看症状 ═══ +banner "编译(-g 留下调试信息)与症状" +gcc -g -O0 -o buggy buggy.c +./buggy +./buggy | grep -Eq 'total = -?[0-9]+' || { echo "FATAL: 输出不符合预期" >&2; exit 1; } + +# ═══ 会话一:断点 + 参数 + 回溯 ═══ +banner "gdb 会话一:断点、info args、bt" +gdb -q -batch -iex 'set debuginfod enabled off' ./buggy \ + -ex 'break scale' \ + -ex 'run' \ + -ex 'info args' \ + -ex 'bt' \ + | tee gdb1.log + +grep -q 'Breakpoint 1, ' gdb1.log || { echo "FATAL: 断点未命中" >&2; exit 1; } +grep -q 'factor = 2' gdb1.log || { echo "FATAL: info args 输出异常" >&2; exit 1; } +grep -Eq '#0 +scale' gdb1.log || { echo "FATAL: 回溯缺 scale 帧" >&2; exit 1; } +grep -Eq '#1 .*main' gdb1.log || { echo "FATAL: 回溯缺 main 帧" >&2; exit 1; } + +# ═══ 会话二:一路 continue 到第五次调用,看越界值 ═══ +banner "gdb 会话二:continue 到 data[4] 那一步" +gdb -q -batch -iex 'set debuginfod enabled off' ./buggy \ + -ex 'break scale' \ + -ex 'run' \ + -ex 'info args' \ + -ex 'continue' \ + -ex 'info args' \ + -ex 'continue' \ + -ex 'continue' \ + -ex 'continue' \ + -ex 'continue' \ + | tee gdb2.log + +# 第五次调用一定发生(循环 i=0..4 共五次),且 v 是越界读到的值 +stops="$(grep -c 'Breakpoint 1, ' gdb2.log)" +[ "$stops" -ge 5 ] || { echo "FATAL: 断点应命中 5 次,实际 $stops 次" >&2; exit 1; } +grep -q 'v = ' gdb2.log || { echo "FATAL: 缺 v 的打印" >&2; exit 1; } + +# ═══ 会话三:watch —— 让数据变化自己举手 ═══ +banner "gdb 会话三:watch total" +gdb -q -batch -iex 'set debuginfod enabled off' ./buggy \ + -ex 'break main' \ + -ex 'run' \ + -ex 'watch total' \ + -ex 'continue' \ + | tee gdb3.log + +grep -q 'Old value = 0' gdb3.log || { echo "FATAL: watch 未捕获首次写入" >&2; exit 1; } +grep -q 'New value = 2' gdb3.log || { echo "FATAL: watch 首次写入值异常" >&2; exit 1; } + +# ═══ 会话四:-O2 下,变量被优化掉 ═══ +banner "-O2:优化和调试器打架" +gcc -O2 -g -o buggy-o2 buggy.c +gdb -q -batch -iex 'set debuginfod enabled off' ./buggy-o2 \ + -ex 'break main' \ + -ex 'run' \ + -ex 'print total' \ + -ex 'print data' \ + | tee gdb4.log + +grep -q 'optimized out' gdb4.log || { echo "FATAL: 应观察到 optimized out" >&2; exit 1; } + +echo +echo "✅ 第 3 个历程 · 程序病了 —— 全部断言通过" diff --git a/scripts/journey/03-make.sh b/scripts/journey/03-make.sh new file mode 100755 index 0000000..5191738 --- /dev/null +++ b/scripts/journey/03-make.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# ── 第 4 个历程 · 程序长大 ──────────────────────────────────────────── +# 重放 tutorial/journey/03-make.md 的全部命令,并断言关键结果。 +# tier: ci-matrix(ubuntu / windows git-bash) +# +# 说明:正文里"文件长这样"的 C 代码块与第一/二幕的 heredoc 逐字一致; +# 最终态文件以 src/journey/03-make/ 为唯一事实源,用 cp 引入。 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SRC="$REPO_ROOT/src/journey/03-make" + +# ── 平台自举:windows runner 上只有 mingw32-make 时,做个 shim ── +if ! command -v make >/dev/null 2>&1; then + if command -v mingw32-make >/dev/null 2>&1; then + SHIM="$(mktemp -d)/bin" + mkdir -p "$SHIM" + printf '#!/usr/bin/env bash\nexec mingw32-make "$@"\n' > "$SHIM/make" + chmod +x "$SHIM/make" + export PATH="$SHIM:$PATH" + echo "[shim] 未找到 make,已将 mingw32-make 映射为 make" + else + echo "FATAL: 平台上没有 make/mingw32-make,无法重放本章" >&2 + exit 1 + fi +fi +command -v gcc >/dev/null 2>&1 || { echo "FATAL: 平台上没有 gcc" >&2; exit 1; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +cd "$WORK" + +banner() { printf '\n──────── %s ────────\n' "$*"; } + +# ═══ 第一幕 · 手工时代:主角还只有一个文件 ═══ +banner "第一幕:单文件时代" + +cat > main.c <<'EOF' +/* 第 2 个历程 出生、第 3 个历程 病愈的主角,目前只有一个文件 */ +#include + +int main(void) +{ + printf("hello, EmbedBox!\n"); + return 0; +} +EOF + +gcc main.c -o hello +./hello +./hello | grep -q 'hello, EmbedBox!' || { echo "FATAL: 输出不符合预期" >&2; exit 1; } + +# 程序长大:greet 搬进 util,main 变小(三个文件见正文代码块) +cat > main.c <<'EOF' +#include +#include "util.h" + +int main(void) +{ + greet("EmbedBox"); + return 0; +} +EOF +cat > util.h <<'EOF' +#ifndef UTIL_H +#define UTIL_H + +/* greet 从 main.c 搬了出来,住进自己的家 */ +void greet(const char *who); + +#endif /* UTIL_H */ +EOF +cat > util.c <<'EOF' +#include +#include "util.h" + +void greet(const char *who) +{ + printf("hello, %s!\n", who); +} +EOF + +banner "第一幕:拆成三个文件,手工流水线" +gcc -c main.c +gcc -c util.c +gcc main.o util.o -o hello +./hello +./hello | grep -q 'hello, EmbedBox!' || { echo "FATAL: 输出不符合预期" >&2; exit 1; } + +# ═══ 第二幕 · 长大的痛:改了三处,只记得重编一个 ═══ +banner "第二幕:程序又长了,你只记得改过 main.c" + +# util.h 长出 version 声明、util.c 长出实现、main.c 用上了它—— +# 三个文件都换了新版(最终态 = src/journey/03-make/) +cp "$SRC/main.c" "$SRC/util.h" "$SRC/util.c" . + +gcc -c main.c +set +e +out="$(gcc main.o util.o -o hello 2>&1)" +rc=$? +set -e +printf '%s\n' "$out" +if [ "$rc" -eq 0 ]; then + echo "FATAL: 预期链接失败,却链接成功了" >&2 + exit 1 +fi +printf '%s\n' "$out" | grep -q 'undefined reference to .version' \ + || { echo 'FATAL: 报错里没有 undefined reference to `version' >&2; exit 1; } + +banner "第二幕:补上忘掉的那一步" +gcc -c util.c +gcc main.o util.o -o hello +./hello +./hello | grep -q 'journey beat 03: v0.3.0' || { echo "FATAL: 输出不符合预期" >&2; exit 1; } + +# ═══ 第三幕 · make 接管 ═══ +banner "第三幕:清掉手工残骸,让 make 接管" +rm main.o util.o hello +cp "$SRC/Makefile" . + +out="$(make 2>&1)" +printf '%s\n' "$out" +printf '%s\n' "$out" | grep -q 'main\.c' || { echo "FATAL: 首次 make 没有编译 main.c" >&2; exit 1; } +printf '%s\n' "$out" | grep -q 'util\.c' || { echo "FATAL: 首次 make 没有编译 util.c" >&2; exit 1; } +./hello +./hello | grep -q 'journey beat 03: v0.3.0' || { echo "FATAL: 输出不符合预期" >&2; exit 1; } + +banner "第三幕:touch util.h —— 头文件变了,两个都要重编" +touch util.h +out="$(make 2>&1)" +printf '%s\n' "$out" +printf '%s\n' "$out" | grep -q 'main\.c' || { echo "FATAL: 头文件变了,main.c 没有重编" >&2; exit 1; } +printf '%s\n' "$out" | grep -q 'util\.c' || { echo "FATAL: 头文件变了,util.c 没有重编" >&2; exit 1; } + +banner "第三幕:什么都不改 —— make 说无事可做" +out="$(make 2>&1)" +printf '%s\n' "$out" +printf '%s\n' "$out" | grep -Eq '(Nothing to be done|is up to date)' \ + || { echo "FATAL: 没有改动时 make 应当无事可做" >&2; exit 1; } + +banner "第三幕:touch util.c —— 只重编真正变了的那个" +touch util.c +out="$(make 2>&1)" +printf '%s\n' "$out" +printf '%s\n' "$out" | grep -q 'util\.c' || { echo "FATAL: util.c 没有重编" >&2; exit 1; } +if printf '%s\n' "$out" | grep -q 'main\.c'; then + echo "FATAL: util.c 的改动不应该触发 main.c 重编" >&2 + exit 1 +fi + +banner "第三幕:make clean 与从零再来一遍" +make clean +[ ! -e hello ] || { echo "FATAL: clean 之后 hello 还在" >&2; exit 1; } +make +./hello | grep -q 'journey beat 03: v0.3.0' || { echo "FATAL: 输出不符合预期" >&2; exit 1; } + +echo +echo "✅ 第 4 个历程 · 程序长大 —— 全部断言通过" diff --git a/scripts/journey/04-cmake.sh b/scripts/journey/04-cmake.sh new file mode 100755 index 0000000..09df44a --- /dev/null +++ b/scripts/journey/04-cmake.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# ── 第 5 个历程 · 工程化 ────────────────────────────────────────────── +# 重放 tutorial/journey/04-cmake.md 的全部命令:CMake 配置、构建、 +# 运行,验证 compile_commands.json,并复验增量构建语义。 +# tier: ci-matrix(产物名在 Windows 上可能带 .exe,脚本做了兼容) +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SRC="$REPO_ROOT/src/journey/04-cmake" + +command -v cmake >/dev/null 2>&1 || { echo "FATAL: 没有 cmake,请回第 1 个历程 补装" >&2; exit 1; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +cp -r "$SRC/." "$WORK/" +cd "$WORK" + +banner() { printf '\n──────── %s ────────\n' "$*"; } + +# 定位产物:Linux/macOS 是 build/hello,部分 Windows 生成器是 hello.exe +find_bin() { + for cand in build/hello build/hello.exe build/Debug/hello.exe build/Release/hello.exe; do + [ -e "$cand" ] && { printf '%s' "$cand"; return 0; } + done + return 1 +} + +# ═══ 配置 + 构建 + 运行 ═══ +banner "cmake -S -B:配置(生成构建系统)" +cmake -S . -B build | tail -4 + +banner "cmake --build:构建" +cmake --build build | tail -8 + +BIN="$(find_bin)" || { echo "FATAL: 没找到构建产物 hello" >&2; exit 1; } +echo "产物: $BIN" +# 运行:./build/hello(Windows 生成器的产物名可能带 .exe,由上面的 find_bin 解析) + +banner "运行" +./"$BIN" +./"$BIN" | grep -q 'journey beat 04: v0.4.0' || { echo "FATAL: 输出不符合预期" >&2; exit 1; } + +# ═══ compile_commands.json:给编辑器的那枚接口 ═══ +banner "compile_commands.json" +ls -l build/compile_commands.json +grep -q 'util\.c' build/compile_commands.json \ + || { echo "FATAL: compile_commands.json 里没有 util.c" >&2; exit 1; } + +# ═══ 增量构建:CMake 记着第 4 个历程 那本账 ═══ +banner "touch src/util.h 之后再构建" +touch src/util.h +out="$(cmake --build build 2>&1)" +printf '%s\n' "$out" +printf '%s\n' "$out" | grep -q 'Building C object.*util\.c' \ + || { echo "FATAL: util.h 变了,util.c 没有重编" >&2; exit 1; } +printf '%s\n' "$out" | grep -q 'Building C object.*main\.c' \ + || { echo "FATAL: util.h 变了,main.c 没有重编" >&2; exit 1; } + +banner "什么都不动,再构建一次" +out="$(cmake --build build 2>&1)" +printf '%s\n' "$out" +if printf '%s\n' "$out" | grep -q 'Building C object'; then + echo "FATAL: 没有改动时不应重编" >&2 + exit 1 +fi + +echo +echo "✅ 第 5 个历程 · 工程化 —— 全部断言通过" diff --git a/scripts/journey/05-cross.sh b/scripts/journey/05-cross.sh new file mode 100755 index 0000000..1bdf58c --- /dev/null +++ b/scripts/journey/05-cross.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# ── 第 6 个历程 · 搬家:交叉编译 ────────────────────────────────────── +# 重放 tutorial/journey/05-cross.md 的全部命令:同一份源码交给 +# arm-none-eabi 工具链,对比架构产物,并验证「宿主机跑不了」。 +# tier: ci-linux 起步(ubuntu runner 可 apt 装 gcc-arm-none-eabi) +# +# 工具链安装指引(第 1 个历程 体检的建议工具,现在该装了): +# Ubuntu/Debian: sudo apt install gcc-arm-none-eabi +# Arch: sudo pacman -S arm-none-eabi-gcc +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SRC="$REPO_ROOT/src/journey/05-cross" + +command -v gcc >/dev/null 2>&1 || { echo "FATAL: 没有 gcc" >&2; exit 1; } +command -v arm-none-eabi-gcc >/dev/null 2>&1 \ + || { echo "FATAL: 没有 arm-none-eabi-gcc,安装指引见本文件头部注释" >&2; exit 1; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +cd "$WORK" +cp "$SRC/main.c" "$SRC/util.c" "$SRC/util.h" . + +banner() { printf '\n──────── %s ────────\n' "$*"; } + +# ═══ 同一个 gcc,不同的目标 ═══ +banner "两个 dumpmachine:编译器各自身后的目标机" +gcc -dumpmachine +arm-none-eabi-gcc -dumpmachine + +assert_dump() { # 断言:目标三元组应以 want 开头(各发行版中段可有可无) + local tool="$1" want="$2" + got="$("$tool" -dumpmachine)" + case "$got" in + "$want"*) ;; + *) echo "FATAL: $tool -dumpmachine = $got,期望 $want 前缀" >&2; exit 1 ;; + esac +} +assert_dump gcc x86_64 +assert_dump arm-none-eabi-gcc arm-none-eabi + +# ═══ 交叉编译,逐段走 ═══ +banner "交叉编译:编目标文件" +arm-none-eabi-gcc -c main.c -o main.o +arm-none-eabi-gcc -c util.c -o util.o + +banner "交叉链接(--specs=rdimon.specs:printf 的裸机后端)" +arm-none-eabi-gcc main.o util.o --specs=rdimon.specs -o hello.elf + +banner "架构对比:同一份 main.c,两个世界" +gcc -c main.c -o main-host.o +objdump -f main-host.o +arm-none-eabi-objdump -f main.o + +# 断言:两份目标文件架构不同,ARM 版真的是 arm +objdump -f main-host.o | grep -q 'x86-64' || { echo "FATAL: 宿主目标文件架构异常" >&2; exit 1; } +arm-none-eabi-objdump -f main.o | grep -q 'architecture: arm' \ + || { echo "FATAL: 交叉目标文件架构不是 arm" >&2; exit 1; } + +arm-none-eabi-readelf -h hello.elf | grep -E 'Class|Machine' + +banner "ELF → 裸二进制" +arm-none-eabi-objcopy -O binary hello.elf hello.bin +ls -l hello.elf hello.bin + +# 断言:.bin 是从 .elf 里抠出的裸字节,一定更小 +elf_size="$(stat -c %s hello.elf)" +bin_size="$(stat -c %s hello.bin)" +[ "$bin_size" -lt "$elf_size" ] \ + || { echo "FATAL: hello.bin($bin_size) 应当比 hello.elf($elf_size) 小" >&2; exit 1; } + +banner "在宿主机上跑它?试试" +set +e +out="$(./hello.elf 2>&1)" +rc=$? +set -e +printf '%s\n' "$out" +if [ "$rc" -eq 0 ]; then + echo "FATAL: ARM 程序不应该能在 x86 宿主机上运行成功" >&2 + exit 1 +fi + +echo +echo "✅ 第 6 个历程 · 搬家 —— 全部断言通过(心脏造好了,还差身体)" diff --git a/scripts/journey/06-qemu-uart.sh b/scripts/journey/06-qemu-uart.sh new file mode 100755 index 0000000..0dc7ead --- /dev/null +++ b/scripts/journey/06-qemu-uart.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# ── 第 7 个历程 · 没有屏幕的机器 ────────────────────────────────────── +# 重放 tutorial/journey/06-qemu-uart.md 的全部命令:裸机构建、 +# QEMU 运行、串口证据与 expected-serial.txt 全量比对、CMake 工具链文件收编、 +# gdbstub 远程调试。 +# tier: ci-linux(ubuntu:apt 装 gcc-arm-none-eabi + qemu-system-arm) +# +# 工具安装指引(Ubuntu): +# sudo apt install gcc-arm-none-eabi qemu-system-arm gdb cmake +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SRC="$REPO_ROOT/src/journey/06-qemu-uart" + +command -v arm-none-eabi-gcc >/dev/null 2>&1 || { echo "FATAL: 缺 arm-none-eabi-gcc(第 6 个历程 已装过)" >&2; exit 1; } +command -v qemu-system-arm >/dev/null 2>&1 || { echo "FATAL: 缺 qemu-system-arm,安装指引见本文件头部注释" >&2; exit 1; } +command -v gdb >/dev/null 2>&1 || { echo "FATAL: 缺 gdb" >&2; exit 1; } +command -v cmake >/dev/null 2>&1 || { echo "FATAL: 缺 cmake" >&2; exit 1; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +cd "$WORK" +cp "$SRC/startup.c" "$SRC/main.c" "$SRC/linker.ld" "$SRC/expected-serial.txt" . +cp "$SRC/CMakeLists.txt" "$SRC/arm-none-eabi.cmake" . + +banner() { printf '\n──────── %s ────────\n' "$*"; } + +# ═══ 构建:点名 CPU,自己带链接脚本 ═══ +banner "交叉编译(点名 cortex-m3 / thumb-2)" +arm-none-eabi-gcc -c -mcpu=cortex-m3 -mthumb -Wall -Wextra -g -O2 startup.c -o startup.o +arm-none-eabi-gcc -c -mcpu=cortex-m3 -mthumb -Wall -Wextra -g -O2 main.c -o main.o + +banner "链接(-nostdlib:这次谁都不借)" +arm-none-eabi-gcc -nostdlib -T linker.ld startup.o main.o -o hello.elf + +arm-none-eabi-objdump -f hello.elf +arm-none-eabi-objdump -f hello.elf | grep -q 'architecture: arm' \ + || { echo "FATAL: 产物架构不是 arm" >&2; exit 1; } +arm-none-eabi-nm hello.elf | grep -q ' T main' || { echo "FATAL: 缺 main 符号" >&2; exit 1; } + +arm-none-eabi-objcopy -O binary hello.elf hello.bin +ls -l hello.elf hello.bin + +# 断言:没有 newlib/半主机拖家带口,镜像应当轻装 +bin_size="$(stat -c %s hello.bin)" +[ "$bin_size" -lt 4096 ] \ + || { echo "FATAL: 裸机镜像 $bin_size 字节,超出预期(应轻装)" >&2; exit 1; } + +# ═══ 运行:串口就是这台机器唯一的嘴 ═══ +banner "QEMU:上电(5 秒后关机)" +set +e +timeout 5 qemu-system-arm -M mps2-an385 -cpu cortex-m3 -nographic -monitor none \ + -kernel hello.elf > serial.txt 2> qemu.err +rc=$? +set -e +if [ "$rc" -ne 124 ] && [ "$rc" -ne 0 ]; then + echo "FATAL: QEMU 异常退出(rc=$rc)" >&2; cat qemu.err >&2; exit 1 +fi + +echo "── 串口输出 ──" +cat serial.txt + +# ═══ 证据落袋:与 expected-serial.txt 全量比对 ═══ +banner "diff:串口输出 vs expected-serial.txt" +if diff -u expected-serial.txt serial.txt; then + echo "两份逐字节一致" +else + echo "FATAL: 串口输出与期望不符" >&2 + exit 1 +fi + +# ═══ 兑现第 5 个历程:换一个工具链文件,重新配置 ═══ +banner "CMake:换一个工具链文件,重新配置" +cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake +cmake --build build + +arm-none-eabi-objcopy -O binary build/hello.elf hello-cmake.bin +cmp hello.bin hello-cmake.bin \ + || { echo "FATAL: CMake 产物与手敲产物不一致" >&2; exit 1; } +echo "── CMake 产物与手敲产物逐字节一致 ──" + +set +e +timeout 5 qemu-system-arm -M mps2-an385 -cpu cortex-m3 -nographic -monitor none \ + -kernel build/hello.elf > serial-cmake.txt 2> qemu-cmake.err +rc=$? +set -e +if [ "$rc" -ne 124 ] && [ "$rc" -ne 0 ]; then + echo "FATAL: QEMU(CMake 产物)异常退出(rc=$rc)" >&2; cat qemu-cmake.err >&2; exit 1 +fi +diff -u expected-serial.txt serial-cmake.txt \ + || { echo "FATAL: CMake 产物的串口输出与期望不符" >&2; exit 1; } + +# ═══ 远程调试:第 3 个历程 的伏笔在此兑现 ═══ +banner "gdbstub:target remote,隔空断点" +GDB_PORT=12345 # 固定端口,正文命令逐字可核 +# Ubuntu 的原生 gdb 是单架构构建,连 ARM 目标会报 unknown architecture "arm"; +# 那边需要 gdb-multiarch。Arch 等发行版的 gdb 本身就是全架构的,直接用。 +GDB_BIN=gdb +command -v gdb-multiarch >/dev/null 2>&1 && GDB_BIN=gdb-multiarch +qemu-system-arm -M mps2-an385 -cpu cortex-m3 -nographic -monitor none \ + -kernel hello.elf -S -gdb tcp::12345 > /dev/null 2>&1 & +QEMU_PID=$! +cleanup_qemu() { kill "$QEMU_PID" 2>/dev/null || true; } +trap cleanup_qemu EXIT +sleep 1 +set +e +timeout 20 "$GDB_BIN" -q -batch -iex 'set debuginfod enabled off' \ + -ex "target remote localhost:12345" \ + -ex 'break main' \ + -ex 'continue' \ + -ex 'bt' \ + hello.elf | tee gdb.log +gdb_rc=$? +set -e +cleanup_qemu +trap 'rm -rf "$WORK"' EXIT +if [ "$gdb_rc" -ne 0 ]; then + echo "FATAL: 远程调试会话失败(rc=$gdb_rc)" >&2 + exit 1 +fi + +grep -q 'Breakpoint 1, ' gdb.log || { echo "FATAL: 远程断点未命中" >&2; exit 1; } +grep -Eq '#0 +main' gdb.log || { echo "FATAL: 回溯缺 main 帧" >&2; exit 1; } +grep -q 'Reset_Handler' gdb.log || { echo "FATAL: 连接时应先停在复位入口" >&2; exit 1; } + +echo +echo "✅ 第 7 个历程 · 没有屏幕的机器 —— 全部断言通过(终点事件完成)" diff --git a/scripts/journey/07-git.sh b/scripts/journey/07-git.sh new file mode 100755 index 0000000..8bdf425 --- /dev/null +++ b/scripts/journey/07-git.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# ── 第 8 个历程 · 记录旅程 ──────────────────────────────────────────── +# 重放 tutorial/journey/07-git.md 的全部命令:在临时目录里 +# 从零建仓、改坏再救回、制造并解决冲突、打上里程碑 tag。 +# tier: ci-matrix +set -euo pipefail + +command -v git >/dev/null 2>&1 || { echo "FATAL: 没有 git" >&2; exit 1; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +banner() { printf '\n──────── %s ────────\n' "$*"; } + +# ═══ 建仓:第一份家业 ═══ +banner "git init:从零建仓" +cd "$WORK" +git init -b main box +cd box +git config user.name "Journey Learner" +git config user.email "learner@example.com" + +cat > README.md <<'EOF' +# box —— 一个程序的一生 + +主线实验仓:EmbedBox journey 的动手记录。 + +## 状态 + +- 第 7 个历程:串口输出已捕获(证据在 expected-serial.txt) +EOF + +cat > hello.c <<'EOF' +#include + +int main(void) +{ + printf("hello, EmbedBox!\n"); + return 0; +} +EOF + +banner "git status:两件新家当还没登记" +git status + +banner "登记 + 第一笔户口" +git add README.md hello.c +git commit -m "hello: journey starts" + +# ═══ 长大:改动与差异 ═══ +banner "程序又长大了一行" +cat > hello.c <<'EOF' +#include + +int main(void) +{ + printf("hello, EmbedBox!\n"); + printf("journey: v0.2\n"); + return 0; +} +EOF + +banner "git diff:改了什么,一目了然" +git diff + +git diff | grep -q 'journey: v0.2' || { echo "FATAL: diff 里应当有新行" >&2; exit 1; } + +banner "git add + 第二笔户口" +git add hello.c +git commit -m "hello: add version line" + +banner "git log --oneline:户口本" +git log --oneline +[ "$(git log --oneline | wc -l)" -ge 2 ] || { echo "FATAL: 应当至少两笔提交" >&2; exit 1; } + +# ═══ 改坏了?历史来救 ═══ +banner "手滑改坏,先别慌" +cat > hello.c <<'EOF' +#include + +int main(void) +{ + /* 手滑:整个 main 被清空了 */ + return 0; +} +EOF +git diff --stat + +banner "git restore:从最近一笔户口恢复" +git restore hello.c +grep -q 'journey: v0.2' hello.c || { echo "FATAL: 恢复失败" >&2; exit 1; } +echo "hello.c 已恢复,文件内容和最近一次提交一致" + +# ═══ 分支:在不打扰主线的地方折腾 ═══ +banner "开分支改 README" +git checkout -b polish-readme +cat > README.md <<'EOF' +# box —— 一个程序的一生 + +主线实验仓:EmbedBox journey 的动手记录。 + +## 状态 + +- 第 7 个历程:串口输出已验证 diff 一致 +EOF +git add README.md +git commit -m "readme: sharpen beat-06 note" + +banner "回主线,改同一行" +git checkout main +cat > README.md <<'EOF' +# box —— 一个程序的一生 + +主线实验仓:EmbedBox journey 的动手记录。 + +## 状态 + +- 第 7 个历程:串口输出已捕获,连 \r\n 都是亲手发的 +EOF +git add README.md +git commit -m "readme: enrich beat-06 note" + +# ═══ 合并:冲突,以及它的解法 ═══ +banner "git merge:两边动了同一行" +set +e +out="$(git merge polish-readme 2>&1)" +rc=$? +set -e +printf '%s\n' "$out" +if [ "$rc" -eq 0 ]; then + echo "FATAL: 预期冲突,却合并成功了" >&2 + exit 1 +fi +printf '%s\n' "$out" | grep -q 'CONFLICT (content)' \ + || { echo "FATAL: 输出缺 CONFLICT 标记" >&2; exit 1; } + +banner "git status:冲突现场" +git status + +banner "手工裁决,然后收尾" +cat > README.md <<'EOF' +# box —— 一个程序的一生 + +主线实验仓:EmbedBox journey 的动手记录。 + +## 状态 + +- 第 7 个历程:串口输出已捕获,且与 expected-serial.txt 逐字节一致 +EOF +git add README.md +git commit -m "merge polish-readme: pick the precise wording" + +# ═══ 里程碑:tag ═══ +banner "打上里程碑" +git tag v0.1-journey +git tag +git tag | grep -q 'v0.1-journey' || { echo "FATAL: tag 缺失" >&2; exit 1; } + +git log --oneline + +echo +echo "✅ 第 8 个历程 · 记录旅程 —— 全部断言通过" diff --git a/scripts/journey/08-vscode.sh b/scripts/journey/08-vscode.sh new file mode 100755 index 0000000..54288bb --- /dev/null +++ b/scripts/journey/08-vscode.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# ── 第 9 个历程 · 编辑器接线 ────────────────────────────────────────── +# tier: manual —— 编辑器交互无法无人值守重放,本脚本只做机械部分: +# 配置文件存在性 + JSON 合法性;其余为人工走查清单(见正文末)。 +# 用 VS Code 打开工程:code . +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DOTVS="$REPO_ROOT/src/journey/08-vscode/.vscode" + +command -v node >/dev/null 2>&1 || { echo "FATAL: 缺 node(本仓库构建本身就需要它)" >&2; exit 1; } + +banner() { printf '\n──────── %s ────────\n' "$*"; } + +banner "机械检查:三份配置存在且是合法 JSON" +# 注意:路径一律走 argv,不拼进 -e 的 JS 字符串——git-bash 调 Windows 原生 node 时 +# 只转换参数里的 POSIX 路径,嵌在字符串里的 /d/a/... 会被 node 当成「当前盘的 \d\a\...」。 +for f in settings.json launch.json tasks.json; do + [ -e "$DOTVS/$f" ] || { echo "FATAL: 缺 $DOTVS/$f" >&2; exit 1; } + node -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" "$DOTVS/$f" + echo " [ok] $f" +done + +banner "机械检查:launch 引用的 preLaunchTask 在 tasks.json 里存在" +task_label="$(node -e "console.log(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).configurations[0].preLaunchTask)" "$DOTVS/launch.json")" +node -e "const ts=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).tasks; process.exit(ts.some(t=>t.label==='$task_label')?0:1)" "$DOTVS/tasks.json" \ + || { echo "FATAL: tasks.json 里没有 '$task_label'" >&2; exit 1; } +echo " [ok] preLaunchTask '$task_label' 可解析" + +banner "人工走查清单(编辑器交互,需真人执行)" +cat <<'EOF' + [ ] 在第 5 个历程 的工程根目录放入本目录的 .vscode/,code . 打开 + [ ] IntelliSense 生效:src/util.c 里 greet 跳转定义可用,无红线 + [ ] F5 触发 cmake-build 并启动 gdb 会话,断点可命中 + [ ] Remote-WSL(Windows 用户):左下角绿色角标显示 WSL 发行版 +EOF + +echo +echo "✅ 第 9 个历程 · 编辑器接线 —— 机械部分通过;人工部分见清单" diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..fa27d8f --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# 正文命令与配对脚本的一致性检查(铁律 2:命令即引用)。 +# +# 规则:tutorial/journey/NN-x.md 的 ```bash/```sh 围栏里,每条非注释命令行 +# 必须逐字出现在 scripts/journey/NN-x.sh 中(containment 匹配)。 +# 约定:命令放 ```bash 围栏;输出/文件内容放无语言或 ```c/```text 围栏; +# ```shell 围栏是「终端实录」——提示符、命令、输出混排的教学演示, +# 不承诺 CI 重放,不参与对账。 +# 模式:默认 warn(只报告);LINT_STRICT=1 时有告警即红。 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +STRICT="${LINT_STRICT:-0}" + +shopt -s nullglob +docs=("$REPO_ROOT"/tutorial/journey/*.md) +shopt -u nullglob + +if [ "${#docs[@]}" -eq 0 ]; then + echo "[lint] tutorial/journey/ 下暂时没有正文" + exit 0 +fi + +warnings=0 +checked=0 + +for md in "${docs[@]}"; do + base="$(basename "$md" .md)" + # index 页是导览,不配脚本 + [ "$base" = "index" ] && continue + + script="$REPO_ROOT/scripts/journey/$base.sh" + if [ ! -e "$script" ]; then + # manual 级允许无配对脚本(编辑器交互/需硬件),但必须在 frontmatter 声明 tier + if grep -q '^tier: *manual' "$md"; then + continue + fi + echo "[lint] ⚠ $base.md: 缺配对脚本 scripts/journey/$base.sh" + warnings=$((warnings + 1)) + continue + fi + + # 抽取 bash 围栏中的命令行,落到临时文件再逐条核对 + cmds_tmp="$(mktemp)" + trap 'rm -f "$cmds_tmp"' EXIT + awk ' + /^```(bash|sh)[[:space:]]*$/ { inblock = 1; next } + /^```/ { inblock = 0; next } + inblock { + line = $0 + sub(/^\$ /, "", line) # 去掉手写的提示符 + if (line ~ /^[[:space:]]*#/) next # 注释行 + if (line ~ /^[[:space:]]*$/) next # 空行 + if (line ~ /\\$/) next # 续行:v1 先跳过 + print line + } + ' "$md" > "$cmds_tmp" + + while IFS= read -r cmd; do + checked=$((checked + 1)) + if ! grep -Fq -- "$cmd" "$script"; then + echo "[lint] ⚠ $base.md: 命令未见于配对脚本 → $cmd" + warnings=$((warnings + 1)) + fi + done < "$cmds_tmp" +done + +echo +echo "[lint] 核对命令 $checked 条,告警 $warnings 条(模式:$([ "$STRICT" = 1 ] && echo strict || echo warn))" +if [ "$STRICT" = 1 ] && [ "$warnings" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/scripts/run-all.sh b/scripts/run-all.sh new file mode 100755 index 0000000..edf5ad0 --- /dev/null +++ b/scripts/run-all.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# EmbedBox 验证总入口:顺序重放全部主线脚本。CI 与本地共用。 +# 任何一个脚本红,总出口码为红。 +# tier 感知:配对正文 frontmatter 声明 tier: ci-linux 的脚本, +# 只在 Linux runner 上重放(交叉工具链/模拟器只装在 Linux 侧); +# 其余 tier(含未声明)照常全平台重放。 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +is_linux() { case "$(uname -s)" in Linux*) return 0 ;; *) return 1 ;; esac; } + +tier_of() { + local md="$REPO_ROOT/tutorial/journey/$1.md" + if [ -f "$md" ]; then + sed -n 's/^tier:[[:space:]]*//p' "$md" | head -n1 + fi + return 0 +} + +shopt -s nullglob +scripts=("$REPO_ROOT"/scripts/journey/*.sh) +shopt -u nullglob + +if [ "${#scripts[@]}" -eq 0 ]; then + echo "[run-all] scripts/journey/ 下暂时没有脚本" + exit 0 +fi + +fail=0 +for script in "${scripts[@]}"; do + base="$(basename "$script" .sh)" + tier="$(tier_of "$base")" + echo + if [ "$tier" = "ci-linux" ] && ! is_linux; then + echo ">>> 跳过 $base.sh(tier: ci-linux,非 Linux runner)" + continue + fi + echo ">>> 重放 $(basename "$script")" + if bash "$script"; then + echo "<<< $(basename "$script") 绿" + else + echo "<<< $(basename "$script") 红" >&2 + fail=1 + fi +done + +exit "$fail" diff --git a/site/.vitepress/config/index.ts b/site/.vitepress/config/index.ts index 2aa52ff..a845a8a 100644 --- a/site/.vitepress/config/index.ts +++ b/site/.vitepress/config/index.ts @@ -71,7 +71,7 @@ export default defineConfig({ locales: buildLocales(), head: [ - ['link', { rel: 'icon', href: projectConfig.favicon || `${projectConfig.base}favicon.ico` }], + ['link', { rel: 'icon', type: 'image/svg+xml', href: projectConfig.favicon || `${projectConfig.base}favicon.ico` }], ], markdown: { @@ -94,6 +94,7 @@ export default defineConfig({ }, themeConfig: { + logo: `${projectConfig.base}embedbox-mark.svg`, nav: projectConfig.nav[primaryLocale.code] || [], sidebar: buildSidebar(docsRoot, projectConfig), diff --git a/site/.vitepress/public/embedbox-mark.svg b/site/.vitepress/public/embedbox-mark.svg new file mode 100644 index 0000000..61f0d90 --- /dev/null +++ b/site/.vitepress/public/embedbox-mark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/site/.vitepress/theme/components/brand/EbMark.vue b/site/.vitepress/theme/components/brand/EbMark.vue new file mode 100644 index 0000000..df58733 --- /dev/null +++ b/site/.vitepress/theme/components/brand/EbMark.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/site/.vitepress/theme/components/home/EbAfterword.vue b/site/.vitepress/theme/components/home/EbAfterword.vue new file mode 100644 index 0000000..ef6394b --- /dev/null +++ b/site/.vitepress/theme/components/home/EbAfterword.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/site/.vitepress/theme/components/home/EbColophon.vue b/site/.vitepress/theme/components/home/EbColophon.vue new file mode 100644 index 0000000..95bf82d --- /dev/null +++ b/site/.vitepress/theme/components/home/EbColophon.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/site/.vitepress/theme/components/home/EbEvidenceTerminal.vue b/site/.vitepress/theme/components/home/EbEvidenceTerminal.vue new file mode 100644 index 0000000..2be0665 --- /dev/null +++ b/site/.vitepress/theme/components/home/EbEvidenceTerminal.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/site/.vitepress/theme/components/home/EbHero.vue b/site/.vitepress/theme/components/home/EbHero.vue new file mode 100644 index 0000000..903449f --- /dev/null +++ b/site/.vitepress/theme/components/home/EbHero.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/site/.vitepress/theme/components/home/EbJourneyLedger.vue b/site/.vitepress/theme/components/home/EbJourneyLedger.vue new file mode 100644 index 0000000..5166b2c --- /dev/null +++ b/site/.vitepress/theme/components/home/EbJourneyLedger.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/site/.vitepress/theme/components/home/journey-data.ts b/site/.vitepress/theme/components/home/journey-data.ts new file mode 100644 index 0000000..01d4d2c --- /dev/null +++ b/site/.vitepress/theme/components/home/journey-data.ts @@ -0,0 +1,57 @@ +// 首页数据源(卷宗终端版) +// 暂为手工快照;蓝图第 4 步落地后,由 scripts/journey 生成 journey.json +// 并在 CI 中校验与本文件一致,防止「改了脚本、首页还在展示旧输出」。 +// 会话行的真实性约束:每一行都必须能在 src/journey/06-qemu-uart/ 或 +// scripts/journey/06-qemu-uart.sh 中找到出处,不许手写编造。 +// 术语:首页一律用「历程」(第几个历程),不再用「拍」。 + +export interface Stage { + label: string + title: string + story: string + link: string + tier: 'ci' | 'manual' +} + +export const STAGES: Stage[] = [ + { label: '第 1 个历程', title: '造机器', story: 'clone 下来先确认机器活着', link: '/journey/00-env-check', tier: 'ci' }, + { label: '第 2 个历程', title: '造程序', story: '看着源码一步步变成 ELF,再把它拆开看', link: '/journey/01-elf', tier: 'ci' }, + { label: '第 3 个历程', title: '治病', story: '程序被笔者弄坏了,printf 够不到病灶,GDB 出场', link: '/journey/02-gdb', tier: 'ci' }, + { label: '第 4 个历程', title: '程序长大', story: '改了头文件忘重编炸出 undefined reference,make 接管记账', link: '/journey/03-make', tier: 'ci' }, + { label: '第 5 个历程', title: '工程化', story: '库与应用分离,CMake 产出 compile_commands.json', link: '/journey/04-cmake', tier: 'ci' }, + { label: '第 6 个历程', title: '搬家', story: '宿主机二进制目标机跑不了,交叉编译', link: '/journey/05-cross', tier: 'ci' }, + { label: '第 7 个历程', title: '没有屏幕的机器', story: 'QEMU 给身体,串口开口说话,证据落袋', link: '/journey/06-qemu-uart', tier: 'ci' }, + { label: '第 8 个历程', title: '记录旅程', story: 'Git 与 Markdown,让旅程可复现、可交付', link: '/journey/07-git', tier: 'ci' }, + { label: '第 9 个历程', title: '编辑器接线', story: '把工具链接进 VS Code', link: '/journey/08-vscode', tier: 'ci' }, + { label: '尾声', title: '实验安全', story: '3.3V/5V、共地、静电、万用表三招', link: '/journey/91-lab-safety', tier: 'manual' }, +] + +// hero 终端会话 — 出处:scripts/journey/06-qemu-uart.sh + expected-serial.txt +// 注意:「journey beat 06: …」是真实串口输出的原文,不随首页术语调整而改。 +export type SessionLine = { + kind: 'cmd' | 'out' | 'dim' | 'stamp' + text: string + cont?: boolean // 上一条命令的续行 +} + +export const SESSION: SessionLine[] = [ + { kind: 'cmd', text: 'arm-none-eabi-gcc -nostdlib -T linker.ld startup.o main.o -o hello.elf' }, + { kind: 'cmd', text: 'timeout 5 qemu-system-arm -M mps2-an385 -cpu cortex-m3 -nographic \\' }, + { kind: 'cmd', text: ' -monitor none -kernel hello.elf', cont: true }, + { kind: 'out', text: 'hello, EmbedBox!' }, + { kind: 'out', text: 'journey beat 06: no OS, just UART (v0.6.0)' }, + { kind: 'cmd', text: 'diff -u expected-serial.txt serial.txt' }, + { kind: 'dim', text: '# 无输出 —— 串口捕获与仓库预存期望逐字节一致' }, + { kind: 'stamp', text: '[CI VERIFIED] scripts/journey/06-qemu-uart.sh · PASS' }, +] + +export const LEGACY_LINKS = [ + { text: '环境与终端', link: '/environment/' }, + { text: '协作与文档', link: '/collaboration/' }, + { text: '构建系统', link: '/build-system/' }, + { text: '调试', link: '/debugging/' }, + { text: '模拟与交叉', link: '/cross-compile/' }, +] + +export const ACTIONS_URL = + 'https://github.com/Awesome-Embedded-Learning-Studio/EmbedBox/actions' diff --git a/site/.vitepress/theme/custom.css b/site/.vitepress/theme/custom.css index 90652d2..dd63bee 100644 --- a/site/.vitepress/theme/custom.css +++ b/site/.vitepress/theme/custom.css @@ -1,3 +1,61 @@ +/* ================================================================ + EmbedBox Design Tokens — 卷宗终端(2026-08 首页重设计) + 冷灰蓝纸面 + 信号绿;终端 = 卷宗实物,明暗模式同一块深色面板。 + 信号绿纪律:视觉层只属于「程序开口说话」的时刻(prompt / 串口输出 / + 落章);按钮用墨色,不用品牌绿。 + 全站组件只引用变量,不硬编码色值。 + ================================================================ */ + +:root { + /* brand = 信号绿(#047857 对冷灰白底 ≈4.8:1,AA) */ + --vp-c-brand-1: #047857; + --vp-c-brand-2: #065f46; + --vp-c-brand-3: #059669; + --vp-c-brand-soft: rgba(4, 120, 87, 0.09); + + /* 中性 = 冷灰蓝纸面(告别默认蓝白) */ + --vp-c-bg: #f7f8fa; + --vp-c-bg-alt: #eef1f4; + --vp-c-bg-soft: #f0f3f6; + --vp-c-bg-elv: #ffffff; + --vp-c-bg-mute: #e6eaef; + --vp-c-border: #d8dee5; + --vp-c-divider: #e2e7ed; + --vp-c-gutter: #e2e7ed; + --vp-c-text-1: #262b33; + --vp-c-text-2: #5a6270; + --vp-c-text-3: #8b93a1; + + /* 卷宗终端:不随主题反转的深色实物 */ + --eb-term-bg: #0a0f16; + --eb-term-border: #1e2937; + --eb-term-text: #d3dae4; + --eb-term-dim: #7e8a9c; + --eb-term-green: #4ade80; + + --eb-mono: ui-monospace, 'JetBrains Mono', SFMono-Regular, Menlo, + Consolas, monospace; +} + +.dark { + --vp-c-brand-1: #4ade80; + --vp-c-brand-2: #34d399; + --vp-c-brand-3: #86efac; + --vp-c-brand-soft: rgba(74, 222, 128, 0.1); + + --vp-c-bg: #0c1118; + --vp-c-bg-alt: #0f141d; + --vp-c-bg-soft: #121824; + --vp-c-bg-elv: #151c27; + --vp-c-bg-mute: #1b2331; + --vp-c-border: #232d3c; + --vp-c-divider: #1c2432; + --vp-c-gutter: #1c2432; + --vp-c-text-1: #d3d8e0; + --vp-c-text-2: #9aa3b2; + --vp-c-text-3: #6b7484; +} + /* ── Content Typography ──────────────────────────────────────── */ .vp-doc { @@ -56,12 +114,16 @@ background-color: rgba(0, 0, 0, 0.02); } +.dark .vp-doc table tr:nth-child(even) { + background-color: rgba(255, 255, 255, 0.03); +} + /* ── Blockquotes ─────────────────────────────────────────────── */ .vp-doc blockquote { border-left: 4px solid var(--vp-c-brand-1); border-radius: 0 4px 4px 0; - background: rgba(81, 107, 232, 0.04); + background: var(--vp-c-brand-soft); } /* ── Links ───────────────────────────────────────────────────── */ @@ -84,6 +146,10 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } +.dark .vp-doc img { + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); +} + /* ── Print ───────────────────────────────────────────────────── */ @media print { @@ -93,212 +159,6 @@ } } -/* ================================================================ - Home Layout — Hero & Feature Cards - ================================================================ */ - -.VPHero { - padding-bottom: 48px !important; -} - -@media (min-width: 960px) { - .VPHero { - padding-bottom: 64px !important; - } -} - -.VPHome .VPHero::before { - content: ''; - position: absolute; - inset: 0; - z-index: -1; - background: linear-gradient( - 135deg, - var(--vp-c-brand-soft) 0%, - transparent 50%, - var(--vp-c-indigo-soft) 100% - ); - opacity: 0.5; - pointer-events: none; -} - -.VPHero .name.clip { - background: linear-gradient( - 135deg, - var(--vp-c-brand-1) 0%, - var(--vp-c-indigo-1) 50%, - var(--vp-c-purple-1) 100% - ); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; -} - -.VPHero .tagline { - font-weight: 400 !important; - color: var(--vp-c-text-2) !important; -} - -/* ── Feature Card Animation ──────────────────────────────────── */ - -@keyframes feature-fade-up { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.VPFeatures .item { animation: feature-fade-up 0.65s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; } -.VPFeatures .item:nth-child(1) { animation-delay: 0ms; } -.VPFeatures .item:nth-child(2) { animation-delay: 78ms; } -.VPFeatures .item:nth-child(3) { animation-delay: 156ms; } -.VPFeatures .item:nth-child(4) { animation-delay: 234ms; } -.VPFeatures .item:nth-child(5) { animation-delay: 312ms; } -.VPFeatures .item:nth-child(6) { animation-delay: 390ms; } -.VPFeatures .item:nth-child(7) { animation-delay: 468ms; } -.VPFeatures .item:nth-child(8) { animation-delay: 546ms; } -.VPFeatures .item:nth-child(9) { animation-delay: 624ms; } -.VPFeatures .item:nth-child(10) { animation-delay: 702ms; } -.VPFeatures .item:nth-child(11) { animation-delay: 780ms; } -.VPFeatures .item:nth-child(12) { animation-delay: 858ms; } - -@media (prefers-reduced-motion: reduce) { - .VPFeatures .item { - animation: none; - } -} - -/* ── Features Grid ───────────────────────────────────────────── */ - -.VPFeatures { - padding-top: 16px; - padding-bottom: 48px; -} - -/* ── Feature Card ────────────────────────────────────────────── */ - -.VPFeature { - border: 1px solid var(--vp-c-divider) !important; - border-radius: 14px !important; - background-color: var(--vp-c-bg) !important; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), - 0 1px 2px rgba(0, 0, 0, 0.06); - transition: border-color 0.39s ease, - box-shadow 0.39s ease, - transform 0.39s ease; -} - -.VPFeature.link:hover { - border-color: var(--vp-c-brand-1) !important; - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1), - 0 4px 8px rgba(0, 0, 0, 0.06); - transform: translateY(-4px); -} - -.VPFeature .box { - padding: 28px 24px !important; -} - -.VPFeature .icon { - width: 48px !important; - height: 48px !important; - border-radius: 12px !important; - background: linear-gradient( - 135deg, - var(--vp-c-brand-soft) 0%, - var(--vp-c-indigo-soft) 100% - ) !important; - color: var(--vp-c-brand-1) !important; - font-size: 22px !important; - transition: background 0.39s ease, - color 0.39s ease, - transform 0.39s ease; -} - -.VPFeature.link:hover .icon { - transform: scale(1.1); -} - -.VPFeature .title { - font-size: 15px !important; - font-weight: 600 !important; - line-height: 1.5 !important; - color: var(--vp-c-text-1) !important; - transition: color 0.39s ease; -} - -.VPFeature.link:hover .title { - color: var(--vp-c-brand-1) !important; -} - -.VPFeature .details { - font-size: 13px !important; - line-height: 1.7 !important; - color: var(--vp-c-text-2) !important; - font-weight: 400 !important; -} - -.VPFeature .link-text-icon { - transition: transform 0.39s ease; -} - -.VPFeature.link:hover .link-text-icon { - transform: translateX(4px); -} - -/* ── Dark Mode ───────────────────────────────────────────────── */ - -.dark .VPFeature { - background-color: var(--vp-c-bg-elv) !important; - border-color: var(--vp-c-border) !important; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), - 0 1px 2px rgba(0, 0, 0, 0.15); -} - -.dark .VPFeature.link:hover { - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3), - 0 4px 8px rgba(0, 0, 0, 0.2); -} - -.dark .vp-doc blockquote { - background: rgba(81, 107, 232, 0.08); -} - -.dark .vp-doc table tr:nth-child(even) { - background-color: rgba(255, 255, 255, 0.03); -} - -.dark .vp-doc img { - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); -} - -/* ── Responsive ──────────────────────────────────────────────── */ - -@media (max-width: 639px) { - .VPFeature .box { - padding: 20px 18px !important; - } - - .VPFeature .icon { - width: 40px !important; - height: 40px !important; - border-radius: 10px !important; - font-size: 18px !important; - } - - .VPFeature .title { - font-size: 14px !important; - } - - .VPFeature .details { - font-size: 12.5px !important; - } -} - /* ── Content Page Enhancements ───────────────────────────────── */ .vp-doc hr { @@ -312,112 +172,3 @@ border-radius: 8px; margin: 1.2em 0; } - -/* ── Contributor Card Grid ───────────────────────────────────── */ - -.contributor-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - gap: 16px; - margin: 1.5em 0; -} - -.contributor-card { - display: flex; - align-items: flex-start; - gap: 16px; - padding: 24px; - border: 1px solid var(--vp-c-divider); - border-radius: 14px; - background-color: var(--vp-c-bg); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06); - transition: border-color 0.39s ease, box-shadow 0.39s ease, transform 0.39s ease; - text-decoration: none !important; - color: inherit; -} - -.contributor-card:hover { - border-color: var(--vp-c-brand-1); - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1), 0 4px 8px rgba(0, 0, 0, 0.06); - transform: translateY(-4px); -} - -.dark .contributor-card { - background-color: var(--vp-c-bg-elv); - border-color: var(--vp-c-border); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 2px rgba(0, 0, 0, 0.15); -} - -.dark .contributor-card:hover { - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.2); -} - -.contributor-card .card-avatar { - width: 56px; - height: 56px; - aspect-ratio: 1; - border-radius: 50%; - object-fit: cover; - border: 2px solid var(--vp-c-divider); - flex-shrink: 0; - transition: border-color 0.39s ease; -} - -.contributor-card:hover .card-avatar { - border-color: var(--vp-c-brand-1); -} - -.contributor-card .card-body { - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; -} - -.contributor-card .card-name { - font-size: 16px; - font-weight: 600; - color: var(--vp-c-text-1); - text-decoration: none !important; - transition: color 0.39s ease; -} - -.contributor-card .card-name:hover { - color: var(--vp-c-brand-1) !important; -} - -.contributor-card .card-role { - margin: 0; - font-size: 13px; - color: var(--vp-c-text-2); - line-height: 1.5; -} - -.contributor-card .card-types { - margin: 0; - font-size: 15px; - letter-spacing: 2px; -} - -.contributor-card .card-desc { - margin: 0; - font-size: 13px; - color: var(--vp-c-text-3); - line-height: 1.6; -} - -@media (max-width: 639px) { - .contributor-grid { - grid-template-columns: 1fr; - } - - .contributor-card { - padding: 18px; - gap: 12px; - } - - .contributor-card .card-avatar { - width: 44px; - height: 44px; - } -} diff --git a/site/.vitepress/theme/index.ts b/site/.vitepress/theme/index.ts index 2e7e0c6..2e61391 100644 --- a/site/.vitepress/theme/index.ts +++ b/site/.vitepress/theme/index.ts @@ -4,6 +4,10 @@ import type { Theme } from 'vitepress' import HomeTipBanner from './components/HomeTipBanner.vue' import ChapterNav from './components/ChapterNav.vue' import ChapterLink from './components/ChapterLink.vue' +import EbHero from './components/home/EbHero.vue' +import EbJourneyLedger from './components/home/EbJourneyLedger.vue' +import EbAfterword from './components/home/EbAfterword.vue' +import EbColophon from './components/home/EbColophon.vue' import { setupMermaid } from './mermaid-client' import './custom.css' @@ -11,7 +15,11 @@ export default { extends: DefaultTheme, Layout() { return h(DefaultTheme.Layout, null, { - 'home-features-before': () => h(HomeTipBanner) + // 首页门面(卷宗终端):自绘 hero → 图签 → 历程目录 → 跋 → 补课索引。 + // index.md 不写 hero/features frontmatter,VPHero/VPFeatures 不渲染,零覆盖战。 + 'home-hero-before': () => h(EbHero), + 'home-features-before': () => [h(EbJourneyLedger), h(EbAfterword)], + 'home-features-after': () => [h(HomeTipBanner), h(EbColophon)], }) }, enhanceApp({ app }) { diff --git a/src/journey/01-elf/hello.c b/src/journey/01-elf/hello.c new file mode 100644 index 0000000..43a7af5 --- /dev/null +++ b/src/journey/01-elf/hello.c @@ -0,0 +1,10 @@ +/* 第 2 个历程 出生、第 3 个历程 病愈的主角,目前只有一个文件 + * 对应教程:tutorial/journey/01-elf.md + */ +#include + +int main(void) +{ + printf("hello, EmbedBox!\n"); + return 0; +} diff --git a/src/journey/02-gdb/buggy.c b/src/journey/02-gdb/buggy.c new file mode 100644 index 0000000..51d02f3 --- /dev/null +++ b/src/journey/02-gdb/buggy.c @@ -0,0 +1,21 @@ +/* 第 3 个历程 的病号:越界读一颗,答案就歪了 + * 对应教程:tutorial/journey/02-gdb.md + */ +#include + +static int scale(int v, int factor) +{ + return v * factor; +} + +int main(void) +{ + int data[4] = {1, 2, 3, 4}; + int total = 0; + + for (int i = 0; i <= 4; i++) { /* 病根:<=,data[4] 不是我们的 */ + total += scale(data[i], 2); + } + printf("total = %d\n", total); + return 0; +} diff --git a/src/journey/03-make/.gitignore b/src/journey/03-make/.gitignore new file mode 100644 index 0000000..4b312d6 --- /dev/null +++ b/src/journey/03-make/.gitignore @@ -0,0 +1,4 @@ +# 学习者在本目录动手实验的产物 +*.o +hello +hello.exe diff --git a/src/journey/03-make/Makefile b/src/journey/03-make/Makefile new file mode 100644 index 0000000..0685b96 --- /dev/null +++ b/src/journey/03-make/Makefile @@ -0,0 +1,20 @@ +# 主线第 4 个历程 · 程序长大 —— 最终 Makefile:变量 + 模式规则 + 头文件依赖 +# 对应教程:tutorial/journey/03-make.md + +CC := gcc +CFLAGS := -Wall -Wextra -g +TARGET := hello +OBJS := main.o util.o + +.PHONY: all clean +all: $(TARGET) + +$(TARGET): $(OBJS) + $(CC) -o $@ $^ + +# 模式规则:任何 .o 都从同名 .c 编出来,并且都依赖头文件 +%.o: %.c util.h + $(CC) $(CFLAGS) -c -o $@ $< + +clean: + rm -f $(TARGET) $(OBJS) diff --git a/src/journey/03-make/main.c b/src/journey/03-make/main.c new file mode 100644 index 0000000..6a55b91 --- /dev/null +++ b/src/journey/03-make/main.c @@ -0,0 +1,12 @@ +/* 主线第 4 个历程 · 程序长大 —— 主角长成三个文件后的入口 + * 对应教程:tutorial/journey/03-make.md + */ +#include +#include "util.h" + +int main(void) +{ + greet("EmbedBox"); + printf("journey beat 03: %s\n", version()); + return 0; +} diff --git a/src/journey/03-make/util.c b/src/journey/03-make/util.c new file mode 100644 index 0000000..e7344a6 --- /dev/null +++ b/src/journey/03-make/util.c @@ -0,0 +1,12 @@ +#include +#include "util.h" + +void greet(const char *who) +{ + printf("hello, %s!\n", who); +} + +const char *version(void) +{ + return "v0.3.0"; +} diff --git a/src/journey/03-make/util.h b/src/journey/03-make/util.h new file mode 100644 index 0000000..3251b42 --- /dev/null +++ b/src/journey/03-make/util.h @@ -0,0 +1,8 @@ +#ifndef UTIL_H +#define UTIL_H + +/* greet 与 version 从 main.c 里搬了出来,住进自己的家 */ +void greet(const char *who); +const char *version(void); + +#endif /* UTIL_H */ diff --git a/src/journey/04-cmake/CMakeLists.txt b/src/journey/04-cmake/CMakeLists.txt new file mode 100644 index 0000000..8f63772 --- /dev/null +++ b/src/journey/04-cmake/CMakeLists.txt @@ -0,0 +1,16 @@ +# 主线第 5 个历程 · 工程化 —— 同一个程序,搬进 CMake 工程 +# 对应教程:tutorial/journey/04-cmake.md +cmake_minimum_required(VERSION 3.16) + +project(journey_box C) + +set(CMAKE_C_STANDARD 99) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# util 从「几个文件」升格为库:接口(include)随库走,消费者自动可见 +add_library(util STATIC src/util.c) +target_include_directories(util PUBLIC src) + +add_executable(hello src/main.c) +target_link_libraries(hello PRIVATE util) diff --git a/src/journey/04-cmake/src/main.c b/src/journey/04-cmake/src/main.c new file mode 100644 index 0000000..e6ed9b9 --- /dev/null +++ b/src/journey/04-cmake/src/main.c @@ -0,0 +1,12 @@ +/* 主线第 5 个历程 · 工程化 —— 库与应用分离后的入口 + * 对应教程:tutorial/journey/04-cmake.md + */ +#include +#include "util.h" + +int main(void) +{ + greet("EmbedBox"); + printf("journey beat 04: %s\n", version()); + return 0; +} diff --git a/src/journey/04-cmake/src/util.c b/src/journey/04-cmake/src/util.c new file mode 100644 index 0000000..21d771a --- /dev/null +++ b/src/journey/04-cmake/src/util.c @@ -0,0 +1,12 @@ +#include +#include "util.h" + +void greet(const char *who) +{ + printf("hello, %s!\n", who); +} + +const char *version(void) +{ + return "v0.4.0"; +} diff --git a/src/journey/04-cmake/src/util.h b/src/journey/04-cmake/src/util.h new file mode 100644 index 0000000..de295f1 --- /dev/null +++ b/src/journey/04-cmake/src/util.h @@ -0,0 +1,8 @@ +#ifndef UTIL_H +#define UTIL_H + +/* 第 5 个历程:util 升格为静态库,接口不变 */ +void greet(const char *who); +const char *version(void); + +#endif /* UTIL_H */ diff --git a/src/journey/05-cross/main.c b/src/journey/05-cross/main.c new file mode 100644 index 0000000..eda2d24 --- /dev/null +++ b/src/journey/05-cross/main.c @@ -0,0 +1,12 @@ +/* 主线第 6 个历程 · 搬家 —— 同一份源码,换个目标机 + * 对应教程:tutorial/journey/05-cross.md + */ +#include +#include "util.h" + +int main(void) +{ + greet("EmbedBox"); + printf("journey beat 05: %s\n", version()); + return 0; +} diff --git a/src/journey/05-cross/util.c b/src/journey/05-cross/util.c new file mode 100644 index 0000000..12dc71e --- /dev/null +++ b/src/journey/05-cross/util.c @@ -0,0 +1,12 @@ +#include +#include "util.h" + +void greet(const char *who) +{ + printf("hello, %s!\n", who); +} + +const char *version(void) +{ + return "v0.5.0"; +} diff --git a/src/journey/05-cross/util.h b/src/journey/05-cross/util.h new file mode 100644 index 0000000..3ed3bee --- /dev/null +++ b/src/journey/05-cross/util.h @@ -0,0 +1,8 @@ +#ifndef UTIL_H +#define UTIL_H + +/* 接口不变 —— 搬家不该惊动住户 */ +void greet(const char *who); +const char *version(void); + +#endif /* UTIL_H */ diff --git a/src/journey/06-qemu-uart/.gitignore b/src/journey/06-qemu-uart/.gitignore new file mode 100644 index 0000000..b59a236 --- /dev/null +++ b/src/journey/06-qemu-uart/.gitignore @@ -0,0 +1,6 @@ +# 学习者在本目录动手实验的产物 +*.o +hello.elf +hello.bin +serial.txt +qemu.err diff --git a/src/journey/06-qemu-uart/CMakeLists.txt b/src/journey/06-qemu-uart/CMakeLists.txt new file mode 100644 index 0000000..d0513ab --- /dev/null +++ b/src/journey/06-qemu-uart/CMakeLists.txt @@ -0,0 +1,12 @@ +# 主线第 7 个历程 · 没有屏幕的机器 —— 裸机构建交给 CMake 收编 +# 对应教程:tutorial/journey/06-qemu-uart.md +# 配合 arm-none-eabi.cmake(工具链文件)使用,见上文件头的用法 +cmake_minimum_required(VERSION 3.16) + +project(journey_baremetal C) + +add_executable(hello.elf startup.c main.c) +target_compile_options(hello.elf PRIVATE + -mcpu=cortex-m3 -mthumb -Wall -Wextra -g -O2) +target_link_options(hello.elf PRIVATE + -mcpu=cortex-m3 -mthumb -nostdlib -T ${CMAKE_CURRENT_SOURCE_DIR}/linker.ld) diff --git a/src/journey/06-qemu-uart/arm-none-eabi.cmake b/src/journey/06-qemu-uart/arm-none-eabi.cmake new file mode 100644 index 0000000..c4ceca8 --- /dev/null +++ b/src/journey/06-qemu-uart/arm-none-eabi.cmake @@ -0,0 +1,8 @@ +# 主线第 7 个历程 · 没有屏幕的机器 —— 工具链文件:CMake 的「这单活用哪套工具」 +# 对应教程:tutorial/journey/06-qemu-uart.md +# 用法:cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake +set(CMAKE_SYSTEM_NAME Generic) # 裸机:没有操作系统 +set(CMAKE_SYSTEM_PROCESSOR arm) # 目标机是 ARM +set(CMAKE_C_COMPILER arm-none-eabi-gcc) +# 裸机上链不出可执行文件,探测编译器时只编译、不链接 +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) diff --git a/src/journey/06-qemu-uart/expected-serial.txt b/src/journey/06-qemu-uart/expected-serial.txt new file mode 100644 index 0000000..86b2e97 --- /dev/null +++ b/src/journey/06-qemu-uart/expected-serial.txt @@ -0,0 +1,2 @@ +hello, EmbedBox! +journey beat 06: no OS, just UART (v0.6.0) diff --git a/src/journey/06-qemu-uart/linker.ld b/src/journey/06-qemu-uart/linker.ld new file mode 100644 index 0000000..efdc9df --- /dev/null +++ b/src/journey/06-qemu-uart/linker.ld @@ -0,0 +1,40 @@ +/* 主线第 7 个历程 · 没有屏幕的机器 —— 程序的住址由这张纸决定 + * 对应教程:tutorial/journey/06-qemu-uart.md + * 目标:QEMU mps2-an385 —— 代码区在 0x00000000,RAM 在 0x20000000 + */ +ENTRY(Reset_Handler) + +MEMORY +{ + FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 512K + RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 512K +} + +_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈从 RAM 顶端向下长 */ + +SECTIONS +{ + .isr_vector : { + KEEP(*(.isr_vector)) /* 向量表必须是第一块 */ + } > FLASH + + .text : { + *(.text*) + *(.rodata*) + } > FLASH + + _sidata = LOADADDR(.data); /* .data:行李在 flash,人在 RAM */ + + .data : { + _sdata = .; + *(.data*) + _edata = .; + } > RAM AT > FLASH + + .bss : { + _sbss = .; + *(.bss*) + *(COMMON) + _ebss = .; + } > RAM +} diff --git a/src/journey/06-qemu-uart/main.c b/src/journey/06-qemu-uart/main.c new file mode 100644 index 0000000..29f7299 --- /dev/null +++ b/src/journey/06-qemu-uart/main.c @@ -0,0 +1,43 @@ +/* 主线第 7 个历程 · 没有屏幕的机器:串口是我们唯一的嘴 + * 对应教程:tutorial/journey/06-qemu-uart.md + * 目标板:QEMU mps2-an385(Cortex-M3),UART0 = CMSDK APB UART + */ +#include + +#define UART0_BASE 0x40004000UL +#define UART_DATA (*(volatile uint32_t *)(UART0_BASE + 0x00)) +#define UART_STATE (*(volatile uint32_t *)(UART0_BASE + 0x04)) +#define UART_CTRL (*(volatile uint32_t *)(UART0_BASE + 0x08)) +#define UART_BAUDDIV (*(volatile uint32_t *)(UART0_BASE + 0x0C)) + +#define UART_STATE_TXBF (1u << 0) /* 发送缓冲满:满了就等 */ +#define UART_CTRL_TXEN (1u << 0) /* 打开发送 */ + +static void uart_init(void) +{ + UART_BAUDDIV = 16; /* QEMU 不仿真波特率时序;真板按 PCLK/baud 算 */ + UART_CTRL = UART_CTRL_TXEN; /* 我们只需要说话,不需要听 */ +} + +static void uart_putc(char c) +{ + while (UART_STATE & UART_STATE_TXBF) { } + UART_DATA = (uint32_t)c; +} + +static void uart_puts(const char *s) +{ + while (*s) { + if (*s == '\n') + uart_putc('\r'); /* 串口世界的礼貌:\n 前面补一个 \r */ + uart_putc(*s++); + } +} + +int main(void) +{ + uart_init(); + uart_puts("hello, EmbedBox!\n"); + uart_puts("journey beat 06: no OS, just UART (v0.6.0)\n"); + for (;;) { } /* 裸机主循环:永远不许返回 */ +} diff --git a/src/journey/06-qemu-uart/startup.c b/src/journey/06-qemu-uart/startup.c new file mode 100644 index 0000000..a35fb82 --- /dev/null +++ b/src/journey/06-qemu-uart/startup.c @@ -0,0 +1,55 @@ +/* 主线第 7 个历程 · 出生证明:向量表 + 复位流程 + * 对应教程:tutorial/journey/06-qemu-uart.md + * 目标板:QEMU mps2-an385(Cortex-M3) + */ +#include +#include + +/* gcc 会把「抄写循环」识别成 memcpy 调用、把「清零循环」识别成 memset 调用; + * -nostdlib 的世界里没有 libc,所以裸机工程自带这两个最小实现。 */ +void *memcpy(void *dst, const void *src, size_t n) +{ + unsigned char *d = dst; + const unsigned char *s = src; + while (n--) *d++ = *s++; + return dst; +} + +void *memset(void *dst, int c, size_t n) +{ + unsigned char *d = dst; + while (n--) *d++ = (unsigned char)c; + return dst; +} + +/* 这些地址全部由链接脚本(linker.ld)定义 */ +extern uint32_t _estack; /* 初始栈顶 */ +extern uint32_t _sidata; /* .data 的行李在 flash 里的位置 */ +extern uint32_t _sdata; /* .data 在 RAM 里的家 */ +extern uint32_t _edata; +extern uint32_t _sbss; /* .bss 在 RAM 里的家 */ +extern uint32_t _ebss; + +int main(void); + +void Reset_Handler(void) +{ + uint32_t *src = &_sidata; + uint32_t *dst = &_sdata; + while (dst < &_edata) /* .data:把行李从 flash 搬进 RAM */ + *dst++ = *src++; + + for (dst = &_sbss; dst < &_ebss; dst++) /* .bss:按合同清零 */ + *dst = 0; + + (void)main(); + for (;;) { } /* main 不该回来;回来了就原地罚站 */ +} + +/* Cortex-M 的向量表:第 0 项是初始栈顶,第 1 项是复位入口。 + * 硬件复位时自己读这张表,不需要咱们写一行汇编。 */ +__attribute__((section(".isr_vector"), used)) +const uintptr_t vector_table[] = { + (uintptr_t)&_estack, + (uintptr_t)Reset_Handler, +}; diff --git a/src/journey/08-vscode/.vscode/launch.json b/src/journey/08-vscode/.vscode/launch.json new file mode 100644 index 0000000..13adb92 --- /dev/null +++ b/src/journey/08-vscode/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "(gdb) hello", + "type": "cppdbg", + "request": "launch", + "program": "${workspaceFolder}/build/hello", + "args": [], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [], + "externalConsole": false, + "MIMode": "gdb", + "preLaunchTask": "cmake-build" + } + ] +} diff --git a/src/journey/08-vscode/.vscode/settings.json b/src/journey/08-vscode/.vscode/settings.json new file mode 100644 index 0000000..2d7b514 --- /dev/null +++ b/src/journey/08-vscode/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "C_Cpp.default.compileCommands": "${workspaceFolder}/build/compile_commands.json", + "files.associations": { + "*.h": "c" + }, + "editor.insertSpaces": true, + "editor.tabSize": 4 +} diff --git a/src/journey/08-vscode/.vscode/tasks.json b/src/journey/08-vscode/.vscode/tasks.json new file mode 100644 index 0000000..ebfe2db --- /dev/null +++ b/src/journey/08-vscode/.vscode/tasks.json @@ -0,0 +1,12 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "cmake-build", + "type": "shell", + "command": "cmake --build build", + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": ["$gcc"] + } + ] +} diff --git a/tutorial/index.md b/tutorial/index.md index c9fa5a5..fd70d2e 100644 --- a/tutorial/index.md +++ b/tutorial/index.md @@ -1,40 +1,6 @@ --- layout: home title: EmbedBox -titleTemplate: 嵌入式开发通用工具链教程 - -hero: - name: EmbedBox - text: 嵌入式通用工具链教程 - tagline: 不管走哪条嵌入式航线,都要先会用的工具——终端 / Git / Markdown / GCC / Make / CMake / GDB / 交叉编译 / 串口 / Docker / QEMU。 - actions: - - theme: brand - text: 开始阅读 - link: /getting-started/toolchain-first - - theme: alt - text: Git 完全指南 - link: /collaboration/git/ - -features: - - title: 前置认知 - details: 为什么先学工具、再碰芯片——工具链全景图与同组织各仓库的分工。 - link: /getting-started/ - - title: 开发环境 - details: WSL2 / Linux / 终端与 Shell 进阶(PATH、管道、权限、烧写)/ VS Code。 - link: /environment/ - - title: 协作与文档 - details: Git 团队协作 + 内核 patch 邮件视角、Markdown 写 README / Kconfig / 设备树。 - link: /collaboration/ - - title: 构建系统 - details: GCC 编译链接全流程到 objcopy 裸 .bin、Makefile、CMake 工具链文件。 - link: /build-system/ - - title: 调试 - details: GDB 从段错误到远程调试(target remote / openocd)、串口即嵌入式 stdout。 - link: /debugging/ - - title: 交叉编译与复现 - details: 两前缀与硬浮点 ABI、Docker 钉死工具链、QEMU 无硬件开发、硬件工具速查。 - link: /cross-compile/ - +titleTemplate: 嵌入式中的 CSAPP · 共同实验工作台 +description: 嵌入式开发通用工具链教程(终端 / Git / Markdown / GCC / Make / CMake / GDB / 交叉编译 / 串口 / Docker / QEMU)——从一行 hello.c 到一份 CI 背书的串口运行证据。 --- - -> EmbedBox 是 [Awesome-Embedded-Learning-Studio](https://github.com/Awesome-Embedded-Learning-Studio) 的通用工具教程仓库:只负责把工具教透,不替中心站做导航。 diff --git a/tutorial/journey/00-env-check.md b/tutorial/journey/00-env-check.md new file mode 100644 index 0000000..28dbeaf --- /dev/null +++ b/tutorial/journey/00-env-check.md @@ -0,0 +1,193 @@ +--- +title: 第 1 个历程 · 环境体检:先确认机器活着 +order: 0 +verify: scripts/journey/00-env-check.sh +tier: ci-matrix +verified-on: WSL2(Arch)/ bash 5.3 / git 2.55 / gcc 16.1.1 / make 4.4.1;CI:ubuntu-latest +--- + +# 第 1 个历程 · 环境体检:先确认机器活着 + +嘿!大伙好!咱们这里是EmbededBox,也就是所有嵌入式软件开发旅途的第一站 + +> 硬件呢?嗯。。。这个版本还闲不考虑硬件。笔者不会,就这么简单。 + +我相信大伙是在以观看网页的方式来阅读我们的教程。不过从这里开始,如果您是多屏用户,请把这里的教程丢到侧屏。如果不是,我建议您以窗口模式放到一边。至少不要让咱们的教程占据您的屏幕。您的核心是动手,跟笔者一起来做。 + +我们开始的方式非常简单——那就是继续阅读我们的教程。至少现在,您什么都不用做! + +## 先得有一个能跑命令的地方 + +好了,咱们偷懒的日子结束了。什么叫能跑命令的地方呢?笔者现在在的就是一个。 + +![I am working in VSCode](assets/00/vscode-cmdline.png) + +没看到?您可以稍微花费一些时间琢磨一下,您认为哪里是一个“能跑命令的地方”。如果您觉得这个太难了,那么这个也是,这个是大名鼎鼎的Windows cmd在Windows11的模样,如果感到陌生,您的确应该抛弃掉您的老电脑了(当然不会!) + +![CMD的截图](assets/00/cmd.png) + +当然如果您是一个潮流的人,您可能听说过Powershell。 + +![Powershell的截图](assets/00/powershell.png) + +这些乱七八糟的东西,就是「终端」,您看到那些电视剧中,一些自称程序员的人在一个黑乎乎的窗口里敲击键盘输入命令?恭喜,马上你也要了。 + +好了收回来,我们说的「终端」就是敲命令的地方。Linux 和 macOS 用户已经有了(我没有用过MacOS,这里表示歉意,上述陈述是我使用MacOS的同事说的)。我也相信当您使用这两款操作系统的时候,恐怕不至于看本教程了。如果不知道,请您发挥您的Hacker精神,去查查维基,或者是问问您喜欢的AI。 + +Windows用户是我们这里需要详细讲述的。一个图形的操作系统的习惯使用者可能会对终端这个概念比较陌生,这并不奇怪,但不代表这是应该的。所以,请您务必安装WSL,尽快的熟悉Linux开发环境。 + +WSL的安装,您可以考虑到[安装你的WSL](https://zhuanlan.zhihu.com/p/2017602632177427017)这篇文章进行阅读。或者是站内的[WSL安装教程](../environment/wsl.md)下阅读。取决于你。 + +笔者的终端贴过来是这样的: + +```shell +[charliechen@DESKTOP-65DBAA7 EmbedBox]$ + +``` + +哈?看起来啥都没有在动?没关系,您在这个窗口输入一些东西,比如说我是这样做的。 + +```shell +[charliechen@DESKTOP-65DBAA7 EmbedBox]$ echo hello +hello +[charliechen@DESKTOP-65DBAA7 EmbedBox]$ pwd +/home/charliechen/EmbedBox +[charliechen@DESKTOP-65DBAA7 EmbedBox]$ + +``` + +当然,这里的话,请您提起精神,您看到了咱们输入一些东西,它能够给你一些回应,对吧。不是所有的输入都是会给你回应的 + +```shell +[charliechen@DESKTOP-65DBAA7 EmbedBox]$ echo hello(如果有光标,他停留在这里,因为你没有按下回车) +``` + +这是一种,还有一种他看起来回车了,但是我可以说,压根计算机不认识 + +```shell +[charliechen@DESKTOP-65DBAA7 EmbedBox]$ imcharliechen +bash: imcharliechen: command not found +``` + +他说 `command not found`。人话就是大哥我没找到命令。就像你跟你的朋友说去吃海底捞,他说什么是海底捞一样,回应了,但是跟没回应一样。 + +所以,只有终端看得懂的的东西,才能够被执行。比如说人生的一个哲学问题——我是谁? + +```shell +[charliechen@DESKTOP-65DBAA7 EmbedBox]$ whoami +charliechen +``` + +他说我叫charliechen。 + +比如说我在哪? + +```shell +[charliechen@DESKTOP-65DBAA7 EmbedBox]$ whoami +charliechen +[charliechen@DESKTOP-65DBAA7 EmbedBox]$ pwd +/home/charliechen/EmbedBox +``` + +他说我在目录 `/home/charliechen/EmbedBox`。你几乎肯定跟我这个不一样。输出一个一大堆/串起的路径,就是对的。 + +好了,`whoami` 打印你叫啥, `pwd` 是显示当前你在哪个路径。不错,请记住他。 + +下一步是使用git拉取代码。什么?不会git?或者说您甚至不知道什么是git?没关系,我们来用如下的命令来安装Git: + +```shell +# 这一句话的意思是——安装git。之后咱们用 +sudo apt install git +``` + +然后您会开始安装git,很有可能会让你确认是否下载,请您输入 'y' 后勇敢的回车。您检验git的方式非常的简单。就是像下面这样 + +```shell +[charliechen@DESKTOP-65DBAA7 EmbedBox]$ git --version +git version 2.55.0 +``` +`--verison` 是一种参数的表达形式,就像你问你的同事——嘿,你点个荤菜吧!你会说——好,点一个水煮牛肉。你完成了点的动作,同时带上了水煮牛肉这个菜品。`git --version`的含义一样,就是说明我要用git,用的方式是告诉我他的版本是多少,一个道理。 + +你第一次使用git的方式非常,非常的简单。麻烦您动动小手。输入一下: + +```shell +[charliechen@DESKTOP-65DBAA7 tmp]$ git clone https://github.com/Awesome-Embedded-Learning-Studio/EmbedBox +Cloning into 'EmbedBox'... +remote: Enumerating objects: 193, done. +remote: Counting objects: 100% (193/193), done. +remote: Compressing objects: 100% (135/135), done. +remote: Total 193 (delta 45), reused 179 (delta 32), pack-reused 0 (from 0) +Receiving objects: 100% (193/193), 1.56 MiB | 2.27 MiB/s, done. +Resolving deltas: 100% (45/45), done. +``` + +当然,如果你发现git迟迟没有输出,请学会使用科学上网,这里出于法律考虑,请自行寻找教程。完成之后,请输入 `cd EmbedBox/`,这个的意思是——进入EmbedBox目录,就像您点击文件管理器的文件夹一样。 + +```shell +[charliechen@DESKTOP-65DBAA7 tmp]$ cd EmbedBox/ +[charliechen@DESKTOP-65DBAA7 EmbedBox]$ +``` + +好了现在在EmbedBox了,跑个脚本玩玩。 + +## 下面跑体检 + +笔者给这个历程准备的「实验」不是代码,而是一份体检脚本,它就是本章的配对验证脚本: + +```bash +./scripts/journey/00-env-check.sh +``` + +您可能已经犯迷糊了,这是啥?嗯,就是我写的一些命令,您看不懂,没关系,我保证他不会出问题。 + +下面是笔者这台机器的真实输出(注意第一行——笔者自己也是在 WSL2 里干活的): + +```text +──────── 机器与系统 ──────── +Linux DESKTOP-65DBAA7 6.18.33.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC ... x86_64 GNU/Linux +bash 5.3.15(1)-release + +──────── 必需工具(缺任何一个,体检就是红) ──────── + [ok] git -> /usr/sbin/git + [ok] gcc -> /usr/sbin/gcc + [ok] make -> /usr/sbin/make + +──────── 工具版本 ──────── +git version 2.55.0 +gcc (GCC) 16.1.1 20260728 +GNU Make 4.4.1 + +──────── 建议工具(现在缺不要紧,后面的历程会用到再装) ──────── + [ok] gdb + [ok] cmake + [ok] arm-none-eabi-gcc + [ok] qemu-system-arm +``` + +报告分三层,值得逐层读一遍。**机器与系统**告诉你内核和架构——后面第 6 个历程交叉编译时,「x86_64」这个字眼会变成故事的另一半。**必需工具**是全书的硬门槛:git(拉代码、记录旅程)、gcc(编译)、make(构建),缺任何一个,这个历程的体检就是红的。**建议工具**是后场的队员:gdb 在第 3 个历程上场,cmake 在第 5 个,arm-none-eabi-gcc 和 qemu 要到第 6/7 个——现在缺了完全不用慌。 + +缺什么就补什么。Ubuntu/Debian 用户一把梭: + +```bash +sudo apt install build-essential gdb cmake +``` + +Arch 用户对号入座 `sudo pacman -S base-devel gdb cmake`;交叉工具链和模拟器到第 6 个历程前再装也来得及(Ubuntu:`sudo apt install gcc-arm-none-eabi qemu-system-arm`)。这里先不展开每个包是什么——它们各自的章节会亲自介绍自己。 + +## 顺便学会一件救命的小事:command not found + +总有一天(大概率是今天晚上),咱们会敲出一个命令,终端冷冷回一句 `command not found`。别慌,三板斧: + +第一斧,**它装了吗**: + +```bash +which gcc +``` + +`which` 在 PATH 里找这个命令,找到了就打印它的位置,找不到就一声不吭。第二斧,**它在的地方 shell 知道吗**: + +```bash +echo "$PATH" +``` + +PATH 是 shell 的寻人启事列表,冒号分隔的一串目录。命令明明装了却报 not found,九成是它所在的目录不在这串列表里——第 6 个历程 装完交叉工具链、第 9 个历程 配编辑器时,你还会再遇到这个局面。第三斧,**名字对吗**:拼写、大小写、连字符,以及「我以为我装了」的自我怀疑。这三斧会陪咱们穿过全书,也会陪咱们穿过之后所有的嵌入式,甚至可以说是自己计算机程序员的生涯! diff --git a/tutorial/journey/01-elf.md b/tutorial/journey/01-elf.md new file mode 100644 index 0000000..c981b95 --- /dev/null +++ b/tutorial/journey/01-elf.md @@ -0,0 +1,252 @@ +--- +title: 第 2 个历程 · 源码→程序:看着它变,再拆开看 +order: 1 +verify: scripts/journey/01-elf.sh +tier: ci-matrix +verified-on: WSL2(Arch)/ gcc 16.1.1;CI:ubuntu-latest +--- + +# 第 2 个历程 · 源码→程序:看着它变,再拆开看 + +主角出场了。它现在是这个样子,一行 `hello.c`。您找到的方式是这样的,请回到当时克隆的EmbedBox上,然后 + +```shell +[charliechen@DESKTOP-65DBAA7 EmbedBox]$ cd src/journey/01-elf +[charliechen@DESKTOP-65DBAA7 01-elf]$ ls +hello.c +``` + +`ls` 的意思很简单,就是列出来有什么,咱们这个目录就是一个hello.c。下一步是打开这个文件看看——终端里的「打开」不弹新窗口,用的命令叫 `cat`,它把文件内容整个倒在屏幕上,像把一张纸摊开在咱们面前: + +```shell +[charliechen@DESKTOP-65DBAA7 01-elf]$ cat hello.c +``` + +得到的是。 +```c +/* 第 2 个历程 出生、第 3 个历程 病愈的主角,目前只有一个文件 + * 对应教程:tutorial/journey/01-elf.md + */ +#include + +int main(void) +{ + printf("hello, EmbedBox!\n"); + return 0; +} +``` + +这里笔者得先立个假设:咱们学过最简单的C语言。真的没有的话,把上面这段代码丢给喜欢的AI问一嘴,一分钟的事。然后,一行命令就能让它跑起来: + +```bash +gcc hello.c -o hello && ./hello +``` + +```text +hello, EmbedBox! +``` + +跑起来了。先别急着往下一屏冲,这行命令本身就值得拆一拆——全书咱们都要跟它打交道。`gcc hello.c` 是主体:把源文件交给 gcc;`-o hello` 给产物起名字,`o` 就是 output,不写的话 gcc 默认管它叫 `a.out`,一个来历不明的名字。 + +中间的 `&&` 是「成了再跑」:左边成功,右边才执行——编译要是失败,`./hello` 压根不会跑,免得咱们捧着上次留下的旧文件空欢喜。至于 `./hello` 的 `./`,第 1 个历程里那串 PATH 咱们还有印象:shell 找命令只翻那串目录,当前目录不在名单上,想运行「就在脚边」的这个程序,就得指名道姓,`./` 的意思就是「当前目录下的这个」。 + +拆完这行命令,然后呢?如果咱们此刻的心情是「能跑就行」,那这个历程就是为咱们准备的——因为 **`gcc` 这一个命令里,其实住着四个工具**, 它们接力把文本变成了机器码——CPU 唯一肯吃的东西。接下来咱们把这四棒拆开,一棒一棒亲眼看。 + +## 四棒接力:预处理器、编译器、汇编器、链接器 + +### 第一棒:预处理器 + +`-E` 让 gcc 在预处理之后立刻停下,把结果吐成 `.i` 文件。顺手用 `wc -l` 数一数行数——`wc` 是 word count,`-l` 让它数行: + +```bash +gcc -E hello.c -o hello.i +wc -l hello.i +``` + +```text +848 hello.i +``` + +一行 `#include` 变成了八百多行。看看开头——`head -12` 就是「只取头 12 行」,八百多行没必要全看: + +```bash +head -12 hello.i +``` + +```text +# 0 "hello.c" +# 0 "" +# 0 "" +# 1 "/usr/include/stdc-predef.h" 1 3 +# 0 "" 2 +# 1 "hello.c" + + + +# 1 "/usr/include/stdio.h" 1 3 +# 28 "/usr/include/stdio.h" 3 +# 1 "/usr/include/bits/libc-header-start.h" 1 3 +``` + +预处理器干的事朴素到令人感到好玩和可爱:**把 `#include` 的文件原样抄进来,把 `#define` 的名字换成值**。那些 `# 1 "..."` 行是行号标记,告诉编译器「接下来这段来自哪个文件第几行」——以后咱们看到编译报错指向头文件深处,靠的就是它们指的路。咱们的 hello.c 本体,在这八百多行的最末尾。 + +### 第二棒:编译器 + +看汇编之前,得先垫一句话,不然下一屏对咱们就是纯天书。CPU 不认识 C,它只吃机器码——每条指令在它眼里就是几个二进制字节,后面咱们会亲眼看到,这段 `main` 的开头四个字节是 `55 48 89 e5`。**汇编**就是机器码的人话版:一条汇编指令几乎一比一地对应一段机器码字节,给二进制起了名字——`mov` 是搬,`call` 是喊人。第二棒干的就是「C → 汇编」这一跳,产出的 `hello.s`,是离 C 很远、离 CPU 很近的文字。咱们不需要全读懂,认得它就够了。 + +`-S` 停在汇编之后,产出 `.s`。这次没写 `-o`,`hello.s` 也会自己落在当前目录——`.s` 和 `.o` 这两棒都有默认产物名,而 `-E` 的默认是把结果直接吐到屏幕上,所以第一棒咱们老老实实写了 `-o hello.i`: + +```bash +gcc -S hello.c +``` + +看看 `main` 部分(咱们用 `grep` 先定位它在第几行): + +```bash +grep -n 'main:' hello.s +``` + +```text +9:main: +``` + +```text +main: +.LFB0: + .cfi_startproc + pushq %rbp + .cfi_def_cfa_offset 16 + .cfi_offset 6, -16 + movq %rsp, %rbp + .cfi_def_cfa_register 6 + leaq .LC0(%rip), %rax + movq %rax, %rdi + call puts@PLT + movl $0, %eax + popq %rbp + .cfi_def_cfa 7, 8 + ret +``` + +这一屏允许咱们看不懂大半——笔者第一次正经读汇编,也是头皮发麻。抓住两样东西就够:`%rbp` `%rsp` `%rax` `%rdi` 这些带 `%` 的名字是**寄存器**,CPU 肚子里屈指可数的几个超高速小格,变量想参与运算,得先搬进这些格子;`pushq` `movq` `leaq` `call` 这些打头的是**指令**,即动作本身——存一下、搬一下、算个地址、跳过去喊人。写 C 不需要会写汇编,但混个脸熟很值:嵌入式调到深处,经常只剩汇编肯跟咱们说话。 + +停一下,这里有个彩蛋:**咱们明明写的是 `printf`,汇编里却是 `call puts@PLT`。** 这是编译器干的好事——`printf("...\n")` 这种单参数、以换行结尾的调用,输出效果和 `puts` 一模一样,而 `puts` 更快(不用解析格式串)。`-O0` 是 gcc 的优化档位,数字越大,编译器越敢自作主张地改写咱们的代码;`-O0` 是「一个字都别动」的最低档——连最低档它都要做这类小替换,往后走进 `-O2` 的世界,咱们心里要有数。 + +这个细节顺便提醒咱们:汇编层看到的才是真相,源码只是愿望。那些 `.cfi_*` 是调试 unwind 信息的骨架,现在可以无视;`@PLT` 这个后缀也先按下不表,第四棒它会自己现身。 + +### 第三棒:汇编器 + +`-c` 停在汇编之后,产出目标文件 `.o`: + +```bash +gcc -c hello.c +objdump -f hello.o +``` + +```text + +hello.o: file format elf64-x86-64 +architecture: i386:x86-64, flags 0x00000011: +HAS_RELOC, HAS_SYMS +start address 0x0000000000000000 +``` + +现在它已经是货真价实的机器码了,但注意 flags 里那个 **`HAS_RELOC`**:「含有待重定位项」。翻译成人话:这份机器码里还有一堆空位,等着链接器(您可以叫他代码的整合器)把最终地址填进去。start address 是 0——它自己都不知道自己将来会被放到内存的哪里。 + +再看看它随身带的账本,符号表: + +```bash +nm hello.o +``` + +```text +0000000000000000 T main + U puts +``` + +两行,但信息量巨大。`T main`:我定义了函数 `main`,它在代码段(Text)。`U puts`:我**用了** `puts`,但它的实现不在我这——`U` 是 Undefined,一张借条。这个借条/欠条的意象请记牢,两个历程之后它会变成一次真实的爆炸。 + +### 第四棒:链接器 + +```bash +gcc hello.o -o hello +objdump -f hello +``` + +```text + +hello: file format elf64-x86-64 +architecture: i386:x86-64, flags 0x00000150: +HAS_SYMS, DYNAMIC, D_PAGED +start address 0x0000000000001040 +``` + +对比 `.o`:flags 变了——`HAS_RELOC` 没了,换来 `DYNAMIC`(动态链接)和 `D_PAGED`(按页布局);start address 从 0 变成了 `0x1040`,链接器已经决定好它住哪了。 + +「C 库」和「动态链接」这两个词,笔者前面都是一带而过,这里兑现。**C 库**(常叫 libc)是操作系统自带的零件库,`printf`、`puts` 这些常用函数的实现全住在里面——`#include ` 抄进来的只是说明书,实现本体从来不在咱们的文件里。于是那张 `U puts` 的借条怎么还,链接器有两种还法:把用到的实现整段抄进 `hello`,文件变大,但从此独立生活,这叫静态链接;或者只在 `hello` 里登记一个门牌,运行时再去系统里的 libc 搭伙,这叫动态链接。gcc 在 Linux 上默认走后者,这就是 `DYNAMIC` 的来历——`hello` 里其实**没有** `puts` 的机器码,`call puts@PLT` 里那个前面按下不表的 `@PLT`,就是门牌本身:一小段跳板代码,程序跑到这里,先顺藤摸瓜找到 libc 里的真身,再跳过去。等到第 7 个历程 上裸机、没有 libc 可搭伙,咱们会亲手体会这两种还法的差别。 + +链接器做的事,就是把 `hello.o` 的借条和 C 库的欠条对上账,把那些待重定位的空位填成真地址。跑一下,它活着: + +```bash +./hello +``` + +```text +hello, EmbedBox! +``` + +## 拆开看:可执行文件是一个数据结构 + +「可执行文件」听起来高深,其实它就是一个格式约定的数据结构——ELF(Executable and Linkable Format)。`readelf` 能把它掀开: + +```bash +readelf -S hello +``` + +```text + [12] .text PROGBITS 0000000000001040 00001040 + [14] .rodata PROGBITS 0000000000002000 00002000 + [25] .data PROGBITS 0000000000004008 00003008 + [26] .bss NOBITS 0000000000004018 00003018 +``` + +这里节选了最要紧的四段。`.text` 是代码,只读;`.rodata` 是只读数据——咱们那句 `"hello, EmbedBox!\n"` 字符串就住在这里;`.data` 是有初值的全局/静态变量;`.bss` 最有意思,类型是 **NOBITS**——不占文件一字节,因为「初值为 0 的变量」没必要存,程序启动时内存里划一块零就行。这四个名字,`.text`/`.data`/`.bss`,是嵌入式的通行证:将来咱们在链接脚本里亲手给它们安排地址时(第 7 个历程),今天的这一眼就是全部前置知识。 + +还能反汇编看自己程序的机器码: + +```bash +objdump -d hello +``` + +```text +0000000000001139
: + 1139: 55 push %rbp + 113a: 48 89 e5 mov %rsp,%rbp + 113d: 48 8d 05 c0 0e 00 00 lea 0xec0(%rip),%rax # 2004 <_IO_stdin_used+0x4> + 1144: 48 89 c7 mov %rax,%rdi + 1147: e8 e4 fe ff ff call 1030 + 114c: b8 00 00 00 00 mov $0x0,%eax + 1151: 5d pop %rbp + 1152: c3 ret +``` + +左列是地址,中间是机器码字节,右列是反汇编。和刚才 `.s` 里的 `main` 长得像——本来就是同一段逻辑,只是现在每条指令有了确定的门牌号。最后看个体积报表: + +```bash +size hello +``` + +```text + text data bss dec hex filename + 1411 584 8 2003 7d3 hello +``` + +`.text` 加上 `.rodata` 等,一共 1411 字节——在宿主机上这点体积无人在意;但请记住这个看体积的习惯,等咱们搬去只有几十 KB 内存的小板子,每一行这个报表都是钱。 + +## 这个历程咱们带走了什么 + +四棒接力:预处理器(抄写员)→ 编译器(翻译官)→ 汇编器(打包员)→ 链接器(对账人)。三件随身工具:`objdump -f` 看格式与架构,`nm` 看符号账本,`readelf -S` 看段落布局。四个段名:`.text`/`.rodata`/`.data`/`.bss`。还有两个伏笔:符号表里的 `U`(借条)会在第 4 个历程 炸出 `undefined reference`;到了第 7 个历程 上裸机,没有 libc 可搭伙,段布局也得咱们亲手写进链接脚本——今天记下的每一样,那天都要用上。 + +## 下一站 + +程序健康地活着,只会说一句话。下一个历程,咱们把它弄坏——认真地、蓄意地弄坏——然后请出嵌入式生涯里最重要的搭档之一:GDB。 diff --git a/tutorial/journey/02-gdb.md b/tutorial/journey/02-gdb.md new file mode 100644 index 0000000..eaefce4 --- /dev/null +++ b/tutorial/journey/02-gdb.md @@ -0,0 +1,180 @@ +--- +title: 第 3 个历程 · 程序病了:printf 够不到的地方 +order: 2 +verify: scripts/journey/02-gdb.sh +tier: ci-matrix +verified-on: WSL2(Arch)/ gcc 16.1.1 / gdb 17.2;CI:ubuntu-latest +--- + +# 第 3 个历程 · 程序病了:printf 够不到的地方 + +这个历程的病,是笔者亲手埋的。病人在src/journey/02-gdb/,先睹为快: + +```c +/* 第 3 个历程 的病号:越界读一颗,答案就歪了 + * 对应教程:tutorial/journey/02-gdb.md + */ +#include + +static int scale(int v, int factor) +{ + return v * factor; +} + +int main(void) +{ + int data[4] = {1, 2, 3, 4}; + int total = 0; + + for (int i = 0; i <= 4; i++) { + total += scale(data[i], 2); + } + printf("total = %d\n", total); + return 0; +} +``` + +🤫!您是C语言的老手的话,还请安静一些,对于还在犯猛的朋友,不要怕,您可能只是知道这个代码是存在问题的。 + +代码里只有一处新面孔:`scale` 前面的 `static`,意思是这个函数只在本文件里可见、不怕别的文件撞名——跟本章的病无关,只是个好习惯。 + +```bash +gcc -g -O0 -o buggy buggy.c +./buggy +``` + +```text +total = 20 +``` + +**答案是对的。** 在笔者这台机器上,这次越界读到的恰好是个 0,乘什么都不影响总分——病得悄无声息。咱们那边可能是 42,可能是 -8,甚至直接崩:越界读没有任何合同保证,读到什么全凭内存布局的运气。这正是它比崩溃更可怕的地方:崩溃至少诚实。 + +现在问题来了:怎么**证明**第五次调用真的发生了、`v` 拿到的不是数组里的数?最直觉的办法是往源码里塞 printf 再重新编译——行话叫「插桩」,好比为了量个体温先给病人开一刀。它天生吃亏:每加一句都得整个重新编译,而且本章开头刚见过,越界读到什么全凭内存布局的运气——多塞一句 printf,布局就可能挪动,隔壁邻居换个值,病象当场变样。咱们真正需要的是一行代码都不加,让程序停在任何一行,掀开它的现场。干这一行的工具叫调试器,本书用的是 GNU Debugger——GDB,第 1 个历程体检报告里候场的那位,现在上场。 + +顺带解释刚才编译命令里的两个选择:`-g` 让 gcc 把「行号、变量名、类型」这些调试信息织进二进制——没有它,gdb 看到的是一串无名地址;`-O0` 关掉优化,让机器码老老实实按源码顺序走。为什么优化是调试的大敌,这个历程结尾咱们会亲眼看到。 + +## 会话一:断点、现场、回溯 + +真实使用时,敲 `gdb ./buggy` 进入的是一场交互式对话——咱们敲一句,它答一句。本书为了每场会话都能被 CI 逐字重放,统一改用 `-batch` 加一串 `-ex` 的写法:把要敲的命令提前排好队,让 gdb 一口气执行完,效果与手敲完全一致。CI(持续集成)是仓库自带的自动验证流程——每章都配了一个验证脚本(第 1 个历程跑的那种体检脚本),它们会在云端机器上原样重跑,咱们贴的每份输出敢说「真实」,靠的就是它。命令里还有三处小机关顺带交代:行尾的 `\` 是换行续写,纯为屏幕上好读,并成一行也照跑;`-q` 让 gdb 少打开场广告;`-iex 'set debuginfod enabled off'` 是句咒语,叫它别联网下载调试信息,现在照抄即可,不影响咱们要看的任何东西: + +```bash +gdb -q -batch -iex 'set debuginfod enabled off' ./buggy \ + -ex 'break scale' \ + -ex 'run' \ + -ex 'info args' \ + -ex 'bt' +``` + +```text +Breakpoint 1 at 0x1153: file buggy.c, line 8. +[Thread debugging using libthread_db enabled] +Using host libthread_db library "/usr/lib/libthread_db.so.1". + +Breakpoint 1, scale (v=1, factor=2) at buggy.c:8 +8 return v * factor; +v = 1 +factor = 2 +#0 scale (v=1, factor=2) at buggy.c:8 +#1 0x00005555555551b4 in main () at buggy.c:17 +``` + +逐行读这份成绩单(例外只有两行:`Thread debugging using libthread_db` 是 gdb 关于多线程的自言自语,与咱们无关,跳过)。 + +`Breakpoint 1 at 0x1153` 是 gdb 在 `scale` 入口放了哨兵;程序跑起来,第一次撞上哨兵,它把**整个现场**摆给咱们:`scale (v=1, factor=2)`——函数名、两个参数的值,一目了然,一行 printf 都不用加。 + +`info args` 再把参数单独列一遍。最后的 `bt`(backtrace)回溯的是调用栈:程序每调用一层函数,就在一块专属内存里记一页账,一页叫一个栈帧——第 2 个历程汇编里那条 `pushq %rbp`,存的就是这叠账本的当前页。`#0` 是现在所处的 `scale`,`#1` 是谁叫它来的——`main` 的 `buggy.c:17`。 + +## 会话二:一路 continue,逼出第五次 + +断点只告诉咱们「第一次调用长这样」。现在连续放行四次,守到第五次: + +```bash +gdb -q -batch -iex 'set debuginfod enabled off' ./buggy \ + -ex 'break scale' \ + -ex 'run' \ + -ex 'info args' \ + -ex 'continue' \ + -ex 'continue' \ + -ex 'continue' \ + -ex 'continue' +``` + +```text +Breakpoint 1, scale (v=1, factor=2) at buggy.c:8 +... + +Breakpoint 1, scale (v=2, factor=2) at buggy.c:8 +... + +Breakpoint 1, scale (v=3, factor=2) at buggy.c:8 +... + +Breakpoint 1, scale (v=4, factor=2) at buggy.c:8 +... + +Breakpoint 1, scale (v=0, factor=2) at buggy.c:8 +``` + +第五次来了:**`v=0`**。可数组里只有 1、2、3、4——这个 0 不属于这个数组,它是边界外那位未知邻居的值。病根确诊:`i <= 4` 里的等号。把循环条件改回 `i < 4`,第五次调用就不会发生。顺便说一句,交互模式下咱们的日常三件套是 `next`(下一行,不进函数)、`step`(走进函数)、`print 变量名`(看任意表达式的值)——这三个词加上今天的 `break`/`continue`/`bt`,足够应付大多数现场。 + +## 会话三:watch,让数据变化自己举手 + +还有一招值得入袋:盯梢。`watch total` 给变量装上门铃,谁改它谁触发。装门铃有个前提:`total` 得先存在——它是 `main` 里的局部变量,程序还没跑进 `main` 时,这个名字无处安放,所以得先在 `main` 门口停一脚: + +```bash +gdb -q -batch -iex 'set debuginfod enabled off' ./buggy \ + -ex 'break main' \ + -ex 'run' \ + -ex 'watch total' \ + -ex 'continue' +``` + +```text +Breakpoint 1, main () at buggy.c:12 +12 { +Hardware watchpoint 2: total + +Hardware watchpoint 2: total + +Old value = 0 +New value = 2 +main () at buggy.c:16 +16 for (int i = 0; i <= 4; i++) { +``` + +`Old value = 0 → New value = 2`——第一圈循环把 total 从 0 写成了 2(1×2),连改动的位置都指给咱们看。怀疑某个变量被「神秘之手」改坏时,watch 是终审证据。(它叫 **Hardware** watchpoint,因为靠的是 CPU 的调试寄存器——将来在真板上调试,这个细节会再次出现。) + +## 会话四:-O2,编译器和调试器打架 + +最后兑现开头的伏笔。用 `-O2` 编译同一个文件: + +```bash +gcc -O2 -g -o buggy-o2 buggy.c +gdb -q -batch -iex 'set debuginfod enabled off' ./buggy-o2 \ + -ex 'break main' \ + -ex 'run' \ + -ex 'print total' \ + -ex 'print data' +``` + +```text +buggy.c: In function 'main': +buggy.c:17:18: warning: iteration 4 invokes undefined behavior [-Waggressive-loop-optimizations] + 17 | total += scale(data[i], 2); + | ^~~~~~~~~~~~~~~~~ +buggy.c:16:23: note: within this loop + 16 | for (int i = 0; i <= 4; i++) { + | ~~^~~ +... +$1 = +$2 = +``` + +两个惊喜。其一,编译器自己拉响了警报:`iteration 4 invokes undefined behavior`——「第 4 次迭代(即 i=4 那次)行为未定义」。越界读属于未定义行为(UB),编译器有权做任何假设,-O2 下它看得更远,直接警告咱们。这也是第 4 个历程 会把 `-Wall -Wextra` 写进 Makefile 的原因:警告是免费的第一道防线。其二,进了 gdb,`print total` 和 `print data` 都回一句 ``(前面的 `$1`、`$2` 只是 gdb 给咱们看过的值编的号,之后能用 `$1` 回头引用,与病无关)——优化器认为这些变量没必要在内存里留位置,直接扔进了寄存器或者干脆重排了。**不是 gdb 坏了,是它要观察的对象被优化掉了。** 所以业界的节奏是:调试期 `-O0 -g`,复现并修完,再换 `-O2` 验证——而不是对着一份优化过的二进制抱怨调试器不灵。 + +## 这个历程咱们带走了什么 + +gdb 五件套:`break`、`run`/`continue`、`info args`/`print`、`bt`、`watch`;两枚心法:现场比猜测值钱、调试用 `-O0`。还有一个正式登场的词:**undefined behavior**——它不是「崩一下」的意思,是「从此没有任何保证」的意思。 + +这一整套断点、单步、观察,都发生在「本机进程」上——进程就是正在运行中的程序,`./buggy` 敲下回车那一刻,文件里的程序才活成一个进程。将来在第 7 个历程,程序会住进另一台(虚拟的)机器,那时 gdb 只需要一句 `target remote`,就能隔着一条串口线把这一切原样搬过去——咱们在真板上调试 STM32 时用的 OpenOCD(Keil的朋友可能犯猛,这啥,别着急,您理解为调试单片机的就好。),本质就是那根线另一头的接线员。 + diff --git a/tutorial/journey/03-make.md b/tutorial/journey/03-make.md new file mode 100644 index 0000000..db32f8e --- /dev/null +++ b/tutorial/journey/03-make.md @@ -0,0 +1,303 @@ +--- +title: 第 4 个历程 · 程序长大:让 make 记住咱们记不住的 +order: 3 +verify: scripts/journey/03-make.sh +tier: ci-matrix +verified-on: WSL2(Arch)/ gcc 16.1.1 / GNU Make 4.4.1;CI:ubuntu-latest +--- + +# 第 4 个历程 · 程序长大:让 make 记住咱们记不住的 + +上一个历程结束时,咱们的程序刚被 GDB 救回来,健康,但它只会说一句话,而且所有家当挤在一个 `main.c` 里。这个历程,咱们让它长大——从"一个文件"长成"一个小工程"。长大的代价马上会来:文件一多,「哪些东西需要重新编译」就成了人脑不该背的负担。这个历程的主角就是 make:一个替咱们记住依赖关系的工具。 + +动手地点是 src/journey/03-make/。您收拾一下,准备开始干活了! + +## 第一幕:手工时代 + +主角目前长这样: + +```c +/* 第 2 个历程 出生、第 3 个历程 病愈的主角,目前只有一个文件 */ +#include + +int main(void) +{ + printf("hello, EmbedBox!\n"); + return 0; +} +``` + +一个文件的时候,生活很简单: + +```bash +gcc main.c -o hello +./hello +``` + +```text +hello, EmbedBox! +``` + +然后程序长大了。它学会的问候,值得从 `main.c` 里搬出去,单独住一个家——于是 `greet` 搬进了 `util`,`main.c` 只负责,额,用它! + +```c +#include +#include "util.h" + +int main(void) +{ + greet("EmbedBox"); + return 0; +} +``` + +```c +// 老手说pragma once呢?不着急,一些嵌入式的老编译器恐怕看了犯迷糊。 +#ifndef UTIL_H +#define UTIL_H + +/* greet 从 main.c 搬了出来,住进自己的家 */ +void greet(const char *who); + +#endif /* UTIL_H */ +``` + +```c +#include +#include "util.h" + +void greet(const char *who) +{ + printf("hello, %s!\n", who); +} +``` + +三个文件各司其职。`util.h` 是**声明**——只宣布「有这么个函数,长这个样子」。味道是——我说了有这个东西,是不是真的,我也不知道(后面链接器就会狠狠的惩罚你,就是这个意思) + +像餐厅的菜单;`util.c` 是**实现**,厨房本体;`main.c` 照着菜单点菜。两处新长相顺带交代:`#include "util.h"` 用引号、`` 用尖括号,是在告诉第一棒的抄写员「一个在咱们自己目录里,一个是系统标配」;`util.h` 开头结尾那圈 `#ifndef UTIL_H ... #endif` 叫**包含守卫**,防止同一个头文件被抄两遍——原理是第一次抄时做个记号,再遇到就跳过。至于 `greet` 参数里的 `const char *`,是「字符串参数」的惯用写法,现在照认即可,指针有它自己的大课,不记在本历程账上。 + +现在编译要分两步走了:先把每个 `.c` 编成目标文件(第 2 个历程 的词汇:`-c` 停在汇编之后、链接之前),再把目标文件拼成可执行文件: + +```bash +gcc -c main.c +gcc -c util.c +gcc main.o util.o -o hello +./hello +``` + +```text +hello, EmbedBox! +``` + +值得停下来看一眼刚才发生了什么:`main.o` 里有一个**未决符号** `greet`——`main.c` 只给了借条(调用了它),欠条(它的实现)在 `util.o` 里。链接器的工作就是把借条和欠条对上账。记住这个画面,第二幕它就要出事。 + +## 第二幕:长大的痛 + +程序继续长。这次它想知道自己的版本号,于是三处同时改动:`util.h` 长出 `version` 的声明,`util.c` 长出实现,`main.c` 用上了它: + +```c +#ifndef UTIL_H +#define UTIL_H + +/* greet 与 version 从 main.c 里搬了出来,住进自己的家 */ +void greet(const char *who); +const char *version(void); + +#endif /* UTIL_H */ +``` + +```c +#include +#include "util.h" + +void greet(const char *who) +{ + printf("hello, %s!\n", who); +} + +const char *version(void) +{ + return "v0.3.0"; +} +``` + +```c +/* 主线第 4 个历程 · 程序长大 —— 主角长成三个文件后的入口 + * 对应教程:tutorial/journey/03-make.md + */ +#include +#include "util.h" + +int main(void) +{ + greet("EmbedBox"); + printf("journey beat 03: %s\n", version()); + return 0; +} +``` + +改完三个文件,要重新编译。咱们只记得自己刚动过 `main.c`,于是: + +```bash +gcc -c main.c +gcc main.o util.o -o hello +``` + +```text +/usr/bin/ld: main.o: in function `main': +main.c:(.text+0x14): undefined reference to `version' +collect2: error: ld returned 1 exit status +``` + +这个报错值得逐行读,因为它会陪咱们很多年。第一行说话的是 `/usr/bin/ld`——链接器本身;`main.o: in function main'` 是说账对不上发生在 `main.o` 的 `main` 函数里,`(.text+0x14)` 是这条借条在代码段里的地址(第 2 个历程 讲过的 `.text`,在这里兑现)。第二行是正题:`undefined reference to version'`——`main.o` 拿着 `version` 的借条,但所有目标文件的欠条里都找不到它。因为 `version` 的实现住在新版 `util.c` 里,而手边的 `util.o` 还是旧版编译出来的,里面没有它。第三行的 `collect2` 是 gcc 的链接包装进程,它在替真正的 `ld` 报告退出状态。 + +修法就是补上忘掉的那一步: + +```bash +gcc -c util.c +gcc main.o util.o -o hello +./hello +``` + +```text +hello, EmbedBox! +journey beat 03: v0.3.0 +``` + +事情解决了,但不知道咱们有没有后背发凉:这次靠报错兜住了,是因为签名对不上炸得响。换一种改法——比如只改了函数内部的行为——旧目标文件会**安静地**混进最终程序,链接一句怨言都没有,拿到手的是一份用旧零件拼的「新」程序。三个文件时咱们还能靠记性,等工程长到三十个文件、头文件一层套一层,「改了什么、谁要重编」就不是人脑该干的活了。咱们需要一个替咱们记账的:它知道每个目标文件从哪来、依赖谁,谁变了就重编谁,没变的绝对不碰。这个记账员就是 make。 + +## 第三幕:make 接管 + +先把手工时代的残骸清掉,从一张白纸开始。`rm` 就是删除(remove)——它不进回收站,删了就是删了,拿它点名文件时看清楚再回车: + +```bash +rm main.o util.o hello +``` + +然后写下这个历程的 Makefile(完整文件就在 `src/journey/03-make/Makefile`): + +```make +# 主线第 4 个历程 · 程序长大 —— 最终 Makefile:变量 + 模式规则 + 头文件依赖 +# 对应教程:tutorial/journey/03-make.md + +CC := gcc +CFLAGS := -Wall -Wextra -g +TARGET := hello +OBJS := main.o util.o + +.PHONY: all clean +all: $(TARGET) + +$(TARGET): $(OBJS) + $(CC) -o $@ $^ + +# 模式规则:任何 .o 都从同名 .c 编出来,并且都依赖头文件 +%.o: %.c util.h + $(CC) $(CFLAGS) -c -o $@ $< + +clean: + rm -f $(TARGET) $(OBJS) +``` + +逐块看它在说什么。开头几行的 `:=` 是 make 的赋值写法,暂时当成 `=` 用即可——两者的细微差别是 make 的深水区,不拦咱们的路。这些变量是「一处声明、处处引用」:`CC` 是编译器,`CFLAGS` 是编译选项——`-Wall -Wextra` 把警告开足(第二幕那种安静的事故,警告常常是第一道警报),`-g` 留下调试信息——这个习惯第 3 个历程 就教过咱们。`all` 是默认目标,它只是站在前台指着真正的产物 `hello`;`hello` 依赖两个目标文件,配方里的 `$@` 代表目标本身,`$^` 代表全部依赖——所以那行展开就是 `gcc -o hello main.o util.o`,和手工时代一模一样,只是换了套更耐用的写法。注意链接这一行没有放 `CFLAGS`:链接器只对符号感兴趣,警告和调试选项是编译期的事。 + +最有味道的是 `%.o: %.c util.h` 这条**模式规则**:任意一个 `.o` 都从同名的 `.c` 编出来,并且额外依赖 `util.h`。`$<` 代表第一个依赖,即那个 `.c` 文件。这一行就是第二幕事故的解药——`util.h` 变了,两个 `.o` 都会被判定过期,谁也漏不掉。至于 `.PHONY`,它声明 `all` 和 `clean` 是「动作」不是文件;少了它,哪天目录里恰好出现一个叫 `clean` 的文件,`make clean` 就会报告无事可做——这个坑的原理在 [GNU Make 手册的 PHONY 一节](https://www.gnu.org/software/make/manual/html_node/Phony-Targets.html)写得很清楚。 + +好了,让它干活: + +```bash +make +``` + +我们的屏幕中依次出现: + +```text +gcc -Wall -Wextra -g -c -o main.o main.c +gcc -Wall -Wextra -g -c -o util.o util.c +gcc -o hello main.o util.o +``` + +OK了,这就是完事了,跑一下? + +```bash +./hello +``` + +马上拿到了我们的输出: + +```text +hello, EmbedBox! +journey beat 03: v0.3.0 +``` + +每一行都是 make 替咱们敲的命令——它先回显命令本身,再执行。现在验证记账员是不是真的在记账。先动头文件——`touch` 的作用是把文件的修改时间戳改成「现在」(文件不存在时顺手创建个空的),内容一个字都不改,正好用来模拟「文件被动过了」: + +```bash +touch util.h +make +``` + +```text +gcc -Wall -Wextra -g -c -o main.o main.c +gcc -Wall -Wextra -g -c -o util.o util.c +gcc -o hello main.o util.o +``` + +`util.h` 的时间戳变了,两个目标文件都被判过期,双双重编——第二幕那种「忘了哪一个」的事故,从机制上不存在了。反过来,什么都不动,make 就什么都不做: + +```bash +make +``` + +```text +make: Nothing to be done for 'all'. +``` + +再单独动 `util.c`: + +```bash +touch util.c +make +``` + +```text +gcc -Wall -Wextra -g -c -o util.o util.c +gcc -o hello main.o util.o +``` + +只有 `util.o` 重编,`main.o` 原封不动,然后重新链接。这就是「最小重编」:三十个文件的工程里,这个差别是从「泡杯咖啡等全量」到「回车即完成」的差别——以后咱们编译内核和 BSP(板级支持包——厂商为某块板子备好的那沓驱动与配置)的时候,会对这一行感恩戴德。 + +最后是打扫和重来,验证这套账本从零开始也成立: + +```bash +make clean +make +``` + +这两个命令依次对应的是 + +```text +rm -f hello main.o util.o +``` + +和: + +```text +gcc -Wall -Wextra -g -c -o main.o main.c +gcc -Wall -Wextra -g -c -o util.o util.c +gcc -o hello main.o util.o +``` + +## 看我,我说个事! + +**Tab,不是空格。** Makefile 的配方行必须以真正的 Tab 开头。用空格缩进,咱们会收获: + +```text +Makefile:16: *** missing separator. Stop. +``` + +报错行号指向的正是那条配方。规则依据:[GNU Make 手册的 Recipe Syntax](https://www.gnu.org/software/make/manual/html_node/Recipe-Syntax.html)。编辑器里建议直接把 Makefile 的缩进硬性设为 Tab。 + +**`Nothing to be done` 不是报错。** 它是 make 在说「依赖没变,我什么都没干」。如果咱们明明改了代码却看到这句话,先怀疑自己是不是改错了地方、或者目录不对——make 只看时间戳,它不会撒谎。 + +**头文件依赖会长大。** 咱们手写的 `util.h` 依赖在两个文件时刚好够用;等头文件多起来,「谁 include 了谁」也该交给机器记——`gcc -MMD` 能自动生成依赖文件。这属于把 make 用到深处的手艺,这些放到参考篇再展开,主线先记着这个口子存在。 diff --git a/tutorial/journey/04-cmake.md b/tutorial/journey/04-cmake.md new file mode 100644 index 0000000..0666a2c --- /dev/null +++ b/tutorial/journey/04-cmake.md @@ -0,0 +1,134 @@ +--- +title: 第 5 个历程 · 工程化:CMake 与那枚给编辑器的接口 +order: 4 +verify: scripts/journey/04-cmake.sh +tier: ci-matrix +verified-on: WSL2(Arch)/ cmake 4.4.2 / gcc 16.1.1;CI:ubuntu-latest +--- + +# 第 5 个历程 · 工程化:CMake 与那枚给编辑器的接口 + +三个文件的工程用 CMake,确实有点杀鸡用牛刀——上一个历程的 Makefile 十几行,清清爽爽。但程序的结构变了:`util` 不再是「顺带编的两个文件」,它是一个有名字、有接口的**库**,值得用库的方式被声明和管理。更硬的理由在下一个历程等着:咱们要搬家。宿主机(就是咱们敲命令的这台电脑)和目标机(将来真正跑咱们程序的那台小板子)这对词,是嵌入式世界的地基,下一个历程正式开工——同一份源码要在宿主机工具链和 ARM 工具链两套规则之间切换,手写 Makefile 管一套是清爽,管两套就是手工地毯。趁现在把壳子换好,搬家时才不心疼。 + +动手地点是 src/journey/04-cmake/。程序还是那个程序,只是住进了新户型:`src/` 目录下 `main.c`、`util.c`、`util.h`,版本号跳到 v0.4.0。您 cd 过去,咱们开工。 + +## CMakeLists.txt:用声明代替记账 + +整个工程的新账本只有十几行: + +```cmake +# 主线第 5 个历程 · 工程化 —— 同一个程序,搬进 CMake 工程 +# 对应教程:tutorial/journey/04-cmake.md +cmake_minimum_required(VERSION 3.16) + +project(journey_box C) + +set(CMAKE_C_STANDARD 99) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# util 从「几个文件」升格为库:接口(include)随库走,消费者自动可见 +add_library(util STATIC src/util.c) +target_include_directories(util PUBLIC src) + +add_executable(hello src/main.c) +target_link_libraries(hello PRIVATE util) +``` + +头几行是例行报到:`cmake_minimum_required` 声明本书要的最低 CMake 版本,`project(journey_box C)` 给工程上户口——名字、语言;两个 `set` 把 C 标准定在 99,第 3 个历程 病号循环里那句 `for (int i = 0; ...)`,在循环里声明变量,就是 C99 才有的写法。 + +和上一个历程最大的区别在姿势:Makefile 记的是**过程**(哪个文件先编、命令行怎么拼),CMakeLists 声明的是**事实**(存在一个库叫 util、一个可执行文件叫 hello、后者链接前者)。 + +`add_library(util STATIC src/util.c)` 一行,「编目标文件、打包成静态库 `libutil.a`」的流水线自动成立——STATIC 就是静态库,第 2 个历程 说过的「把用到的实现整段抄进可执行文件」的那种零件包。 + +> `target_include_directories(util PUBLIC src)` 里那个 `PUBLIC` 是全段最需要注意的关键词。编译器默认只在「源文件自己的目录」和系统目录里找头文件,想让它去别处翻,得用 `-I 路径` 明说——上个历程全家同住一层,这个问题藏得深;工程一大、头文件分了家,它立刻冒头。而把 `src` 声明成 PUBLIC 意味着**谁链接 util,谁就自动获得这个 include 路径**——main.c 里 `#include "util.h"` 不需要任何额外配置。库的接口随库走,而不是靠每个消费者自己记得,这是工程化最值钱的一步。`PRIVATE` 则相反:链接关系只属于 hello 自己,不外传。 + +## 配置与构建:两步走 + +```bash +cmake -S . -B build +``` + +```text +-- Detecting C compile features - done +-- Configuring done (0.1s) +-- Generating done (0.0s) +-- Build files have been written to: /tmp/.../build +``` + +注意这条命令**没有编译任何东西**。`-S .` 指源码目录,`-B build` 指输出目录,CMake 此刻干的是「考察环境、生成构建系统」——它探测了咱们的编译器,然后在 `build/` 里生成了一套现成的构建脚本(默认就是 Makefile)。换句话说,CMake 是构建系统的生成器,不是构建系统本身;「我该用哪个编译器、平台是什么」这类问题,由它在配置期一次性回答,不散落在构建规则里。这也是它将来能优雅切换工具链的底气:换一个工具链文件,重新配置,同一份 CMakeLists 纹丝不动。 + +然后才是构建: + +```bash +cmake --build build +``` + +```text +[ 25%] Building C object CMakeFiles/util.dir/src/util.c.o +[ 50%] Linking C static library libutil.a +[ 50%] Built target util +[ 75%] Building C object CMakeFiles/hello.dir/src/main.c.o +[100%] Linking C executable hello +[100%] Built target hello +``` + +看这份进度条式的输出:先编 `util.c`、打成 `libutil.a`,再编 `main.c`、链接成 `hello`——上一个历程咱们手写的依赖关系,这里由目标(target)之间的关系自动推导。运行: + +```bash +./build/hello +``` + +```text +hello, EmbedBox! +journey beat 04: v0.4.0 +``` + +## 那枚给编辑器的接口 + +配置时埋的一行 `set(CMAKE_EXPORT_COMPILE_COMMANDS ON)`,让 `build/` 里多出一个文件: + +```bash +ls -l build/compile_commands.json +``` + +```text +-rw-r--r-- 1 charliechen charliechen 558 Aug 23 14:41 build/compile_commands.json +``` + +`ls` 的 `-l` 是长格式:权限、属主、大小、修改时间一次摊开——开头那串 `-rw-r--r--` 是权限位,属于另一门叫做操作系统这门课程的内容,先跳过,您还犯不着非要跟这个搏斗。 + +这一眼只看两件事,文件在,558 字节,不是空壳。打开看,里面是每个源文件**完整编译命令**的 JSON 清单(JSON 是一种人和程序都能读的结构化文本格式,工具世界的通用语)——用哪个编译器、什么标准、哪些 include 路径。 + +它不是给 CMake 自己用的,是给工具生态的通用接口:编辑器拿到它,就知道每个文件「真实的编译视角」。第 9 个历程 给 VS Code 接线时,IntelliSense 吃的就是这份文件。现在咱们只需要记住:它在 `build/` 里,是构建系统递给编辑器的名片。 + +## 账还记着吗? + +换了大管家,第 4 个历程 用血换来的增量构建语义可不能丢。验证: + +```bash +touch src/util.h +cmake --build build +``` + +```text +[ 25%] Building C object CMakeFiles/util.dir/src/util.c.o +[ 50%] Linking C static library libutil.a +[ 50%] Built target util +[ 75%] Building C object CMakeFiles/hello.dir/src/main.c.o +[100%] Linking C executable hello +[100%] Built target hello +``` + +头文件一变,库和应用双双重编——而且这次不用咱们在规则里手写 `util.h` 依赖,CMake 生成 Makefile 时顺带查了每个文件的 `#include` 关系,第 4 个历程 坑里留的那个 `-MMD` 口子,大管家默认就给封上了。什么都不改再构建一次: + +```bash +cmake --build build +``` + +```text +[ 50%] Built target util +[100%] Built target hello +``` + +只报「已就绪」,一个字都没重编。账本还在,管家换了,服务升级。 diff --git a/tutorial/journey/05-cross.md b/tutorial/journey/05-cross.md new file mode 100644 index 0000000..9223168 --- /dev/null +++ b/tutorial/journey/05-cross.md @@ -0,0 +1,130 @@ +--- +title: 第 6 个历程 · 搬家:交叉编译 +order: 5 +verify: scripts/journey/05-cross.sh +tier: ci-linux +verified-on: WSL2(Arch)/ gcc 16.1.1 + Arm GNU Toolchain arm-none-eabi-gcc;CI:ubuntu-latest(apt 装 gcc-arm-none-eabi) +--- + +# 第 6 个历程 · 搬家:交叉编译 + +程序在宿主机上活得很滋润,但它的命运是住进一台小板子。这个历程咱们给它搬家——结果会是一颗造好却暂时无处安放的心脏,以及一个重要的世界观:**「可执行文件」从来不是一种通用货币,它是特定架构的方言。** + +动手地点是 src/journey/05-cross/。源码和上一个历程几乎一样(greet + version,版本跳到 v0.5.0)——**搬家不该惊动住户**。需要新工具:`arm-none-eabi-gcc`(第 1 个历程 体检里「建议工具」的那位,Ubuntu 用户 `sudo apt install gcc-arm-none-eabi`,Arch 用户 `sudo pacman -S arm-none-eabi-gcc`)。您把工具装好,咱们就发车。 + +## 先认清编译器的身世 + +咱们马上要拥有两个长得一模一样的 gcc,先问清它们各自为谁工作: + +```bash +gcc -dumpmachine +``` + +得到的是—— + +```text +x86_64-pc-linux-gnu +``` + +我们再走: + +```bash +arm-none-eabi-gcc -dumpmachine +``` + +继续拿到: + +```text +arm-none-eabi +``` + +这串叫**目标三元组**(target triplet),是编译器出厂时就烙好的身份:「我生成的代码,给这类机器用」。宿主机 gcc 面向 x86_64 Linux。 + +`arm-none-eabi` 拆开读就是一份自我介绍:**arm** 是 ARM 架构——所谓架构,就是一套 CPU 认识的指令方言,第 2 个历程 咱们看过的 `pushq`、`%rbp` 那些拼写是 x86 的方言,ARM 是另一套,同一段 C 翻过去,拼出来完全两样;**none** 是「没有操作系统」(裸机),**eabi** 是 ARM 家调用约定的一个版本(调用约定就是「参数从哪递、返回值放哪」的家规)。 + +编译器本身都是跑在咱们桌面上的 x86 程序——区别只在它**吐出的代码给谁跑**,理解这个,你就理解了交叉编译的概念。给别的架构生成代码,这就叫交叉编译。干这活的工具链,叫交叉工具链。 + +## 搬家实操 + +流程和第 2 个历程 的四棒接力完全同构,只是每一棒都换了人: + +```bash +arm-none-eabi-gcc -c main.c -o main.o +arm-none-eabi-gcc -c util.c -o util.o +``` + +```bash +arm-none-eabi-gcc main.o util.o --specs=rdimon.specs -o hello.elf +``` + +链接这行多出的 `--specs=rdimon.specs` 值得一句解释。咱们的程序用了 `printf`,而 printf 是 C 库的函数——宿主机上,这个 C 库是 glibc,它收下咱们要打的字之后,还得亲自去请 Linux 内核把字写到屏幕上(这趟「请内核办事」的申请,行话叫系统调用,syscall)。 + +可裸机的世界里没有操作系统,`arm-none-eabi` 工具链配的是面向裸机的精简 C 库 **newlib**,它把「往哪输出」这个问题留了空位。`rdimon`(半主机,semihosting)是一种补位方案:让调试器或模拟器**替**目标机代劳这些请求。它是权宜之计,不是归宿——第 7 个历程 咱们会让程序真正自己开口。 + +现在验证搬家是否成功。用第 2 个历程 的老朋友 `objdump -f`,同一份 `main.c`,两个世界: + +```bash +gcc -c main.c -o main-host.o +objdump -f main-host.o +``` + +```text + +main-host.o: file format elf64-x86-64 +architecture: i386:x86-64, flags 0x00000011: +HAS_RELOC, HAS_SYMS +start address 0x0000000000000000 +``` + +```bash +arm-none-eabi-objdump -f main.o +``` + +```text + +main.o: file format elf32-littlearm +architecture: armv4t, flags 0x00000011: +HAS_RELOC, HAS_SYMS +start address 0x00000000 +``` + +同一份源码,一份是 `elf64-x86-64`,一份是 `elf32-littlearm`——32 位(CPU 一次能啃的字宽,比宿主机的 64 位窄一半)、小端、ARM 指令集。 + +小端(little-endian)是「多字节数据在内存里低位字节排在前」的排法,它的反面叫大端;好在这套工具链和 x86 一样默认小端,咱们暂时不用换脑子——知道世上存在另一种排法就行。再看最终产物的「户口本」。 + +这行命令里有两样新机关:竖线 `|` 叫管道,把左边命令的输出直接接到右边命令的输入上——`readelf` 吐出的长篇户口,整卷递给 `grep` 过筛;而 `grep -E` 表示按正则表达式匹配,`Class|Machine` 里的竖线是「或」,只放行带这两个词的行: + +```bash +arm-none-eabi-readelf -h hello.elf | grep -E 'Class|Machine' +``` + +```text + Class: ELF32 + Machine: ARM +``` + +顺带一提,`armv4t` 是工具链不指定时的默认底档;下一个历程咱们会用 `-mcpu=cortex-m3` 精确点名 CPU,拿到 Thumb-2 这类现代指令集。 + +## 裸字节:从 ELF 里抠出 .bin + +第 2 个历程 说过,ELF 是给**工具**看的容器——里面有段表、符号表、调试信息。但板子上的烧录器(把字节写进板子闪存的那支笔)只认一样东西:从某个地址开始,一字节一字节的裸机器码。`objcopy` 负责把容器拆掉,只取干货: + +```bash +arm-none-eabi-objcopy -O binary hello.elf hello.bin +ls -l hello.elf hello.bin +``` + +```text +-rwxr-xr-x 1 charliechen charliechen 62048 Aug 23 14:43 hello.bin +-rwxr-xr-x 1 charliechen charliechen 377580 Aug 23 14:43 hello.elf +``` + +`.bin` 比 `.elf` 小了一个数量级——容器确实占地方。不过 62KB 对将来的小板子来说仍然太富态:那是半主机后端拖家带口的结果。等下一个历程程序亲自负责输出,镜像会轻装得多。 + +## 现在试试跑它 + +```text +scripts/journey/05-cross.sh: line 74: ./hello.elf: cannot execute binary file: Exec format error +``` + +这是宿主机 shell 的原话:`Exec format error`——**格式不符,拒绝执行**。x86 的内核读不懂 ARM 的 ELF,这不是 bug,是架构的巴别塔。心脏造好了,检验合格,但它还没有身体。 diff --git a/tutorial/journey/06-qemu-uart.md b/tutorial/journey/06-qemu-uart.md new file mode 100644 index 0000000..1e5307b --- /dev/null +++ b/tutorial/journey/06-qemu-uart.md @@ -0,0 +1,366 @@ +--- +title: 第 7 个历程 · 没有屏幕的机器:它亲口向咱们问好 +order: 6 +verify: scripts/journey/06-qemu-uart.sh +tier: ci-linux +verified-on: WSL2(Arch)/ arm-none-eabi-gcc(Arm GNU Toolchain 14.2.1)/ QEMU 11.0.3 / gdb 17.2 / cmake 4.4.2;CI:ubuntu-latest +--- + +# 第 7 个历程 · 没有屏幕的机器:它亲口向咱们问好 + +这是全书的高潮。上一个历程结束时,咱们手里是一颗造好却无处安放的 ARM 心脏;这个历程,QEMU 给它一副身体——一块虚拟的、没有操作系统的、**没有屏幕的**板子。程序将在上面自己开机、自己初始化,然后通过一根串口线,亲口向世界问好。咱们将拿到这本书的签名产物:一份 CI 背书的串口运行证据。 + +咱们的虚拟板叫 `mps2-an385`,CPU 是 Cortex-M3——**和 STM32 主流型号同一个家族**。这不是随手挑的:咱们在这个历程写的每一行启动代码,将来搬到真 STM32 上,骨架几乎不变。 + +动手地点是 src/journey/06-qemu-uart/。需要的工具这回齐了:`arm-none-eabi-gcc`、`qemu-system-arm`(第 1 个历程 的建议工具全部到岗)。您 cd 过去,咱们上电。 + +## 先解决一个哲学问题:谁叫醒 main? + +在宿主机上,`./hello` 之前有一整个操作系统在铺床:加载 ELF、设好栈、把环境变量摆好,然后才跳到 main。裸机上**没有这位管家**——CPU 上电后只做一件事:从地址 0 读两个数,第一个当栈顶,第二个当入口。所以裸机程序的第一份文件不是 main,是**出生证明**: + +```c +/* 主线第 7 个历程 · 出生证明:向量表 + 复位流程 + * 对应教程:tutorial/journey/06-qemu-uart.md + * 目标板:QEMU mps2-an385(Cortex-M3) + */ +#include +#include + +/* gcc 会把「抄写循环」识别成 memcpy 调用、把「清零循环」识别成 memset 调用; + * -nostdlib 的世界里没有 libc,所以裸机工程自带这两个最小实现。 */ +void *memcpy(void *dst, const void *src, size_t n) +{ + unsigned char *d = dst; + const unsigned char *s = src; + while (n--) *d++ = *s++; + return dst; +} + +void *memset(void *dst, int c, size_t n) +{ + unsigned char *d = dst; + while (n--) *d++ = (unsigned char)c; + return dst; +} + +/* 这些地址全部由链接脚本(linker.ld)定义 */ +extern uint32_t _estack; /* 初始栈顶 */ +extern uint32_t _sidata; /* .data 的行李在 flash 里的位置 */ +extern uint32_t _sdata; /* .data 在 RAM 里的家 */ +extern uint32_t _edata; +extern uint32_t _sbss; /* .bss 在 RAM 里的家 */ +extern uint32_t _ebss; + +int main(void); + +void Reset_Handler(void) +{ + uint32_t *src = &_sidata; + uint32_t *dst = &_sdata; + while (dst < &_edata) /* .data:把行李从 flash 搬进 RAM */ + *dst++ = *src++; + + for (dst = &_sbss; dst < &_ebss; dst++) /* .bss:按合同清零 */ + *dst = 0; + + (void)main(); + for (;;) { } /* main 不该回来;回来了就原地罚站 */ +} + +/* Cortex-M 的向量表:第 0 项是初始栈顶,第 1 项是复位入口。 + * 硬件复位时自己读这张表,不需要咱们写一行汇编。 */ +__attribute__((section(".isr_vector"), used)) +const uintptr_t vector_table[] = { + (uintptr_t)&_estack, + (uintptr_t)Reset_Handler, +}; +``` + +开头那对 `memcpy`/`memset` 不是炫技,是**笔者真实的踩坑**:第一版笔者只写了两个裸的 while 循环,链接器当场罢工——`undefined reference to memcpy`。原因是 gcc 的「循环模式识别」会把抄写循环悄悄换成 `memcpy` 调用,而 `-nostdlib` 的世界里没有 libc。咱们以为在写循环,编译器译成了函数调用——汇编层看到的才是真相,这句话在第 2 个历程 就说过了。 + +两样随身小物先认一下:`stdint.h` 里的 `uint32_t` 是「正好 32 位的无符号整数」——把宽度说死,不看 `int` 在不同机器上的脸色,在两套架构之间搬家的路上,这是保命的习惯;`size_t` 则是「能装下任何对象大小」的无符号类型,标准数数用的就是它。然后是这张表的读法。`vector_table` 用 `__attribute__((section(".isr_vector")))` 放进一个专门的段,链接脚本保证它是 flash 的**第一块**——因为硬件复位时无条件从地址 0 读它:第 0 项 `_estack` 是初始栈顶(栈从 RAM 顶端向下长),第 1 项 `Reset_Handler` 是入口。这就是「谁叫醒 main」的完整答案:**硬件读表,跳进 Reset_Handler,它搬完家,再叫 main。** + +而 `Reset_Handler` 干的两件事,正是第 2 个历程 埋的伏笔全部兑现。 + +`.data` 段的变量「初值在 flash、生活在 RAM」,所以要把行李从 flash 抄进 RAM`.bss` 段按合同是全零(NOBITS,不占文件一字节),所以上电第一件事是把那块 RAM 刷干净。当时说「程序启动时内存里划一块零就行」——划零的人,就是这里。 + +对了,`(void)main()` 那个 `(void)` 不是装饰:main 返回 int,咱们把返回值故意扔掉,写明是告诉 `-Wall`「故意的,别念叨」。 + +## 住址由这张纸决定 + +出生证明里那些 `_estack`、`_sdata`,不是变量,是链接脚本签发的地址——`extern` 在这里只是「先报个户口,本体在别处」的声明(第 4 个历程 菜单与厨房的分工),妙就妙在「别处」居然是一份链接脚本。裸机世界里没有操作系统替咱们选加载地址,**每个段住在哪,咱们自己说了算**: + +还是看不懂?没关系,我们之后会有专门的仓库仔细的盘算“链接脚本”这个课程。 + +```ld +/* 主线第 7 个历程 · 没有屏幕的机器 —— 程序的住址由这张纸决定 + * 对应教程:tutorial/journey/06-qemu-uart.md + * 目标:QEMU mps2-an385 —— 代码区在 0x00000000,RAM 在 0x20000000 + */ +ENTRY(Reset_Handler) + +MEMORY +{ + FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 512K + RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 512K +} + +_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈从 RAM 顶端向下长 */ + +SECTIONS +{ + .isr_vector : { + KEEP(*(.isr_vector)) /* 向量表必须是第一块 */ + } > FLASH + + .text : { + *(.text*) + *(.rodata*) + } > FLASH + + _sidata = LOADADDR(.data); /* .data:行李在 flash,人在 RAM */ + + .data : { + _sdata = .; + *(.data*) + _edata = .; + } > RAM AT > FLASH + + .bss : { + _sbss = .; + *(.bss*) + *(COMMON) + _ebss = .; + } > RAM +} +``` + +`MEMORY` 块声明这块板的两片地:flash 从 0 开始(向量表必须住在 0),RAM 从 0x20000000 开始——这两个地址来自板子的手册,QEMU 照着真实硬件建模。 + +`SECTIONS` 里每一行都是第 2 个历程 `readelf -S` 看过的老朋友,只是这次它们的位置**由咱们分配**。分配时手里捏着一支游标:脚本里那个孤零零的点 `.` 表示「当前位置」,`_sdata = .` 就是把游标此刻的读数记下来当书签。`KEEP(*(.isr_vector))` 里的 KEEP 是保险——向量表没有任何代码显式引用它(来读它的是硬件,链接器不知道这层关系),开裁剪时可能被当垃圾扔掉,KEEP 明确说不许。 + +`*(COMMON)` 收的是「无初值全局变量」的老式合并区,跟 `.bss` 一个待遇,顺手一起清零。最有意思的是 `.data : > RAM AT > FLASH`:两个地址——运行地址在 RAM(`> RAM`),存储地址在 flash(`AT > FLASH`),`LOADADDR` 取的正是行李的存身处。Startup 里那趟搬家,搬的就是这两地址之间的差。 + +## 串口:这台机器唯一的嘴 + +没有屏幕、没有 printf、没有操作系统——输出靠什么?靠 **UART**(通用异步收发器,串口这门手艺的主力)。它是一块**外设**:所谓外设,就是 CPU 之外、挂在总线上替 CPU 干活的功能块——串口、定时器、GPIO 都是。咱们往特定地址写一个字节,它就把这个字节变成电平波形发出去。这就是嵌入式世界的老话:**串口就是板子的 stdout**。 + +```c +/* 主线第 7 个历程 · 没有屏幕的机器:串口是我们唯一的嘴 + * 对应教程:tutorial/journey/06-qemu-uart.md + * 目标板:QEMU mps2-an385(Cortex-M3),UART0 = CMSDK APB UART + */ +#include + +#define UART0_BASE 0x40004000UL +#define UART_DATA (*(volatile uint32_t *)(UART0_BASE + 0x00)) +#define UART_STATE (*(volatile uint32_t *)(UART0_BASE + 0x04)) +#define UART_CTRL (*(volatile uint32_t *)(UART0_BASE + 0x08)) +#define UART_BAUDDIV (*(volatile uint32_t *)(UART0_BASE + 0x0C)) + +#define UART_STATE_TXBF (1u << 0) /* 发送缓冲满:满了就等 */ +#define UART_CTRL_TXEN (1u << 0) /* 打开发送 */ + +static void uart_init(void) +{ + UART_BAUDDIV = 16; /* QEMU 不仿真波特率时序;真板按 PCLK/baud 算 */ + UART_CTRL = UART_CTRL_TXEN; /* 我们只需要说话,不需要听 */ +} + +static void uart_putc(char c) +{ + while (UART_STATE & UART_STATE_TXBF) { } + UART_DATA = (uint32_t)c; +} + +static void uart_puts(const char *s) +{ + while (*s) { + if (*s == '\n') + { + /* 串口世界的礼貌:\n 前面补一个 \r */ + uart_putc('\r'); + } + uart_putc(*s++); + } +} + +int main(void) +{ + uart_init(); + uart_puts("hello, EmbedBox!\n"); + uart_puts("journey beat 06: no OS, just UART (v0.6.0)\n"); + for (;;) { } /* 裸机主循环:永远不许返回 */ +} +``` + +这段代码每个字都值得初学者看三遍。`0x40004000` 是这块板 UART0 的基地址(来自板子手册),`+0x00/0x04/0x08` 的偏移是 CMSDK UART 的寄存器排布:DATA 是数据口,STATE 是状态,CTRL 是开关,BAUDDIV 是波特率分频——波特率就是双方约好的「每秒发多少位」的语速,分频值决定它。寄存器里的开关按「位」居住:`1u << 0` 造出一个只有第 0 位是 1 的数,而 `UART_STATE & UART_STATE_TXBF` 只看第 0 位、其余一概不理——位的移与与,是嵌入式每天的算术。最外层那圈 `(*(volatile uint32_t *)...)` 是嵌入式的心跳:不加 `volatile`,编译器看程序循环读一个「没改过的地址」,会好心地把读操作优化掉——于是 CPU 永远等不到缓冲区变空,程序死在等待里。真板和 QEMU 都会如实扮演这个坑。这套「寄存器就是内存里的地址,读写地址就是操作硬件」的打法,行话叫内存映射 IO(MMIO)——CPU 眼里没有什么「外设」分类,只有一批带副作用的地址。 + +`uart_putc` 的逻辑是所有外设驱动的母版:**等硬件准备好,再动手**。TXBF(发送缓冲满)是 UART 在说「上一个字节我还没发完」,咱们就等。`uart_puts` 里补 `\r` 是串口世界的礼貌:很多终端把 `\n` 只当「下移一行」不当「回到行首」,`hello` 会变成阶梯——补一个 `\r` 才是干净的换行。 + +## 上电 + +构建流程和第 6 个历程 同构,几个新面孔:`-mcpu=cortex-m3` 精确点名 CPU,`-mthumb` 选 Thumb 指令集(它的现代形态 Thumb-2,第 6 个历程 提过一嘴),`-nostdlib` 宣布谁都不借,`-T linker.ld` 递上咱们自己签发的住址纸: + +```bash +arm-none-eabi-gcc -c -mcpu=cortex-m3 -mthumb -Wall -Wextra -g -O2 startup.c -o startup.o +arm-none-eabi-gcc -c -mcpu=cortex-m3 -mthumb -Wall -Wextra -g -O2 main.c -o main.o +``` + +```bash +arm-none-eabi-gcc -nostdlib -T linker.ld startup.o main.o -o hello.elf +``` + +先验货。`objdump -f` 这次有三处新看点: + +```bash +arm-none-eabi-objdump -f hello.elf +``` + +```text + +hello.elf: file format elf32-littlearm +architecture: armv7, flags 0x00000112: +EXEC_P, HAS_SYMS, D_PAGED +start address 0x00000009 +``` + +flags 里 `HAS_RELOC` 没了,换来 **`EXEC_P`**——第 2 个历程 讲过,目标文件「有待重定位项」,链接完成后地址全部落定,这回是在咱们自己的链接脚本上落定的。架构栏的 `armv7` 替代了第 6 个历程 的默认档 `armv4t`,这是点名 CPU 的效果。还有一个悬案:start address 是 `0x9`,可 Reset_Handler 明明在地址 0x8?这是 Cortex-M 的 Thumb bit:入口地址的 bit0 置 1 表示「这是 Thumb 指令」,CPU 取指前自动把它抹掉——这个 bit0,在后面的反汇编和链接脚本里还会反复露面。 + +再出裸二进制,看体重: + +```bash +arm-none-eabi-objcopy -O binary hello.elf hello.bin +ls -l hello.elf hello.bin +``` + +```text +-rwxr-xr-x 1 charliechen charliechen 268 Aug 23 14:49 hello.bin +-rwxr-xr-x 1 charliechen charliechen 9388 Aug 23 14:49 hello.elf +``` + +**268 字节。** 上一个历程拖着半主机后端的 `.bin` 是 62KB——这回没有 newlib、没有 rdimon、没有别人的家具,向量表、启动代码、两个驱动函数、两句话,全部家当 268 字节。这就是裸机的体重。 + +然后,上电: + +```bash +timeout 5 qemu-system-arm -M mps2-an385 -cpu cortex-m3 -nographic -monitor none \ + -kernel hello.elf +``` + +五个参数逐个读:`-M mps2-an385` 指定机器型号(板子);`-cpu cortex-m3` 指定 CPU;`-nographic` 关掉图形界面,**把串口直接接到咱们的终端**;`-monitor none` 关掉 QEMU 自带的控制台,免得它和串口抢 stdin(程序的标准输入,就是咱们在终端敲的字);`-kernel hello.elf` 把镜像塞进板子并按下复位键。`timeout 5` 是因为咱们这位主角说完话就进死循环,不打算退休,5 秒后替它关机。终端上出现的是: + +```text +hello, EmbedBox! +journey beat 06: no OS, just UART (v0.6.0) +``` + +不够带劲,我们来看看实际的样子! + +![实际的输出](assets/06/real_output.png) + +**它开口了。** 从一行 `hello.c` 走到这里:被解剖、生过病、长成三个文件、住进 CMake 工程、搬过家——现在它住在一块没有操作系统的板子上,用一根(虚拟)串口线向咱们问好。这就是 community 计划书里那句「在 QEMU 中运行,并保存一次可追溯的串口或运行结果」的前半句。 + +## 证据落袋 + +后半句「可追溯」现在完成。先把串口输出捕获成文件——在运行命令后面接 `> serial.txt`,这叫重定向:把本该打到屏幕上的字,引进文件里存着。然后和仓库里预存的期望值用 `diff` 比对(`-u` 是把差异连同上下文一起摊开的显示格式)。diff 的规矩很有性格:**一致时它一个字都不说**——沉默即通过。您可以试试看用一下。 + +## 把搬家也交给管家:工具链文件 + +手敲上面那三条命令,为的是把每一棒的新面孔看真切。但第 5 个历程 吹过的牛不能烂账——「换一个工具链文件,重新配置」,CMake 在两套工具链之间切换的底气,现在当场兑现。两个新文件,先看小的: + +```cmake +# 主线第 7 个历程 · 没有屏幕的机器 —— 工具链文件:CMake 的「这单活用哪套工具」 +# 对应教程:tutorial/journey/06-qemu-uart.md +# 用法:cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake +set(CMAKE_SYSTEM_NAME Generic) # 裸机:没有操作系统 +set(CMAKE_SYSTEM_PROCESSOR arm) # 目标机是 ARM +set(CMAKE_C_COMPILER arm-none-eabi-gcc) +# 裸机上链不出可执行文件,探测编译器时只编译、不链接 +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) +``` + +工具链文件(toolchain file)就是一张委派书:配置期递给 CMake,告诉它这单活请哪套工具、干给哪种机器。`Generic` 是 CMake 的行话,「没有操作系统」的意思;最后那行是裸机的暗门——CMake 探测编译器时默认要试着链一个可执行文件,而裸机链不出,得让它只编译、不链接。再看工程本体: + +```cmake +# 主线第 7 个历程 · 没有屏幕的机器 —— 裸机构建交给 CMake 收编 +# 对应教程:tutorial/journey/06-qemu-uart.md +# 配合 arm-none-eabi.cmake(工具链文件)使用,见上文件头的用法 +cmake_minimum_required(VERSION 3.16) + +project(journey_baremetal C) + +add_executable(hello.elf startup.c main.c) +target_compile_options(hello.elf PRIVATE + -mcpu=cortex-m3 -mthumb -Wall -Wextra -g -O2) +target_link_options(hello.elf PRIVATE + -mcpu=cortex-m3 -mthumb -nostdlib -T ${CMAKE_CURRENT_SOURCE_DIR}/linker.ld) +``` + +认一认熟人:三条手敲命令一个字没丢,只是换了住处——`-mcpu`/`-mthumb`/警告/`-g`/`-O2` 住进 `target_compile_options`,`-nostdlib` 和 `-T linker.ld` 住进 `target_link_options`(`${CMAKE_CURRENT_SOURCE_DIR}` 是「CMakeLists 所在目录」,防止从别处配置时找不到链接脚本)。还是第 5 个历程 那句话:命令照跑,但咱们维护的是**声明**,不是过程。然后,老两步——注意委派书是配置期递的: + +```bash +cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake +cmake --build build +``` + +```text +-- The C compiler identification is GNU 16.1.0 +-- Check for working C compiler: /usr/sbin/arm-none-eabi-gcc - skipped +-- Configuring done (0.1s) +-- Generating done (0.0s) +-- Build files have been written to: /tmp/.../build +[ 33%] Building C object CMakeFiles/hello.elf.dir/startup.c.obj +[ 66%] Building C object CMakeFiles/hello.elf.dir/main.c.obj +[100%] Linking C executable hello.elf +[100%] Built target hello.elf +``` + +配置日志里那行 `Check for working C compiler ... - skipped`,就是暗门生效的样子。最后验货,两条生产线对撞——`cmp` 比对二进制文件,diff 的表亲,一致时同样一言不发: + +```bash +arm-none-eabi-objcopy -O binary build/hello.elf hello-cmake.bin +cmp hello.bin hello-cmake.bin +``` + +沉默,即逐字节一致。`build/hello.elf` 再进一次 QEMU,串口输出与 expected-serial.txt 依然分毫不差(配对脚本把这整套也纳入了断言)。管家上岗,誓言兑现——往后工程要在宿主机和目标机两边同时构建,就是多一份委派书的事。 + +## 兑现一个伏笔:target remote + +第 3 个历程 结尾说过:这一切终将隔着一条线搬过去。现在程序在另一台机器里,咱们隔着 TCP 隔空调试。动手前交代一处看起来像「自相矛盾」的地方:第 3 个历程 立过「调试用 `-O0`」的规矩,这次却编了 `-O2`——裸机寸土寸金是一层理由,更实在的是今天的断点都落在函数入口,`-O2` 不碍事;真要逐行追变量,把构建里的 `-O2` 换回 `-O0` 重来即可。规矩是活的,取舍要明说: + +```bash +gdb -q -batch -iex 'set debuginfod enabled off' \ + -ex 'target remote localhost:12345' \ + -ex 'break main' \ + -ex 'continue' \ + -ex 'bt' \ + hello.elf +``` + +(先把 QEMU 用 `-S -gdb tcp::12345` 起在后台——`-S` 让它开机即停,`-gdb` 打开一个 gdbstub 服务口。) + +这里有个发行版分岔,先打预防针:Ubuntu 的原生 `gdb` 是单架构构建,连上 ARM 目标会警告 `unknown architecture "arm"`,然后当场失联——Ubuntu 用户要请的是 `gdb-multiarch`(`sudo apt install gdb-multiarch`),之后把命令里的 `gdb` 换成 `gdb-multiarch`,其余一字不动;Arch 这类发行版的 gdb 生来全架构,没有这道坎。配对脚本会自己挑人。 + +```text +Reset_Handler () at startup.c:39 +39 while (dst < &_edata) /* .data:把行李从 flash 搬进 RAM */ +Breakpoint 1 at 0xa8: file main.c, line 18. + +Breakpoint 1, main () at main.c:18 +18 UART_BAUDDIV = 16; /* QEMU 不仿真波特率时序;真板按 PCLK/baud 算 */ +#0 main () at main.c:18 +[Inferior 1 (process 1) detached] +``` + +读这份成绩单:一接上,程序正停在 `Reset_Handler` 搬 `.data` 的行——开机即停的效果;`break main` 之后 `continue`,断点隔着一条 TCP 线**精准命中另一台机器里的 main**,`bt` 递上调用栈(末尾那行 `[Inferior 1 ... detached]` 是 `-batch` 收工时自动松手告辞——inferior 是 gdb 给「被调试者」起的学名)。命令还是第 3 个历程 那五件套,一个字没变,变的只是被调试者住在本机进程还是板子里。将来咱们调试真 STM32,用的 OpenOCD 干的就是 QEMU 这个 `-gdb` 的活:把芯片的调试口翻译成同一套 gdb 协议。今天这堂课,直接复用。 + +## 这个历程咱们带走了什么 + +一份出生证明(向量表 + 复位流程,`.data`/`.bss` 的搬家与清零)、一张住址纸(链接脚本,MEMORY 与 SECTIONS)、一个驱动母版(volatile MMIO + 等硬件准备好)、一枚指纹(expected-serial.txt)、一张委派书(工具链文件,CMake 收编裸机构建——第 5 个历程 的承诺兑现)——还有 `target remote`,和一根永远通向真板的线。268 字节,无借贷,全部家当由咱们自己签名。 + +到这里,**「从 clean clone 到可追溯的串口证据」的完整链条咱们已经亲手走通了一次**。这也是本书主线的技术终点——剩下的几个历程,讲的是怎么和这位新朋友共同生活:怎么记录它、怎么让编辑器认识它、以及怎么把它送上真正的硬件。 + +## 下一站 + +机器开口说话了。但如果这段旅程丢了——代码没处放、改动没法回溯、成果没法交给人——一切等于没发生。下一个历程,把旅程记下来:Git 的深度,和一份像样的 README。 diff --git a/tutorial/journey/07-git.md b/tutorial/journey/07-git.md new file mode 100644 index 0000000..8483e95 --- /dev/null +++ b/tutorial/journey/07-git.md @@ -0,0 +1,220 @@ +--- +title: 第 8 个历程 · 记录旅程:Git 的深度与一份像样的 README +order: 7 +verify: scripts/journey/07-git.sh +tier: ci-matrix +verified-on: WSL2(Arch)/ git 2.55.0;CI:ubuntu-latest +--- + +# 第 8 个历程 · 记录旅程:Git 的深度与一份像样的 README + +咱们在第 1 个历程 就用过 git 了——`git clone` 那条命令,当时说「先照抄,这个历程解释」。现在兑现。机器开口说话了(第 7 个历程),但如果这段旅程没有记录:代码没处放、改动没法回溯、成果没法交给人——一切等于没发生。这个历程咱们把「记录」这件事做扎实:在临时目录里从零建一个小仓,亲手走完 git 的日常闭环,包括一次真实的合并冲突。 + +本章没有 `src/` 实验——实验对象就是一个即建即弃的小仓库,配对脚本会替咱们把它完整重放一遍。 + +## 建仓:第一份家业 + +```bash +git init -b main box +``` + +`git init` 建仓,`-b main` 顺便指定主分支名(不指定的话,新版 git 会友好地提示它打算叫什么)。进到 `box/` 里,放两件家当:咱们的老主角 `hello.c`(单文件时代的那位),和一份 README。然后问 git:「你看到什么了?」 + +```bash +git status +``` + +```text +On branch main + +No commits yet + +Untracked files: + (use "git add ..." to include in what will be committed) + README.md + hello.c + +nothing added to commit but untracked files present (use "git add" to track) +``` + +git 的世界观是一台**户口登记机**:工作目录里的一切,它先当「无户口」(Untracked)看待。`git status` 是咱们和它对账的窗口,这个命令咱们之后每天要敲几十遍。登记,然后落第一笔户口: + +```bash +git add README.md hello.c +git commit -m "hello: journey starts" +``` + +```text +[main (root-commit) 7ad7635] hello: journey starts + 2 files changed, 14 insertions(+) + create mode 100644 README.md + create mode 100644 hello.c +``` + +(那串 `7ad7635` 是提交的指纹——每次提交都不同,咱们跑出来的必然是另一串,这很正常。) + +## 改动、差异与户口本 + +程序长大了一行,加了句 `printf("journey: v0.2\n");`。改完先别急着提交,看看 git 怎么描述这次改动: + +```bash +git diff +``` + +```text +diff --git a/hello.c b/hello.c +index df3d6b5..02323af 100644 +--- a/hello.c ++++ b/hello.c +@@ -3,5 +3,6 @@ + int main(void) + { + printf("hello, EmbedBox!\n"); ++ printf("journey: v0.2\n"); + return 0; + } +``` + +`diff` 是 git 的显微镜:加了哪行、在哪个上下文里,一清二楚。读法认三个记号:`a/hello.c` 和 `b/hello.c` 是改动前、后两版;`@@ -3,5 +3,6 @@` 是路段牌——旧文件第 3 行起的 5 行,换成了新文件第 3 行起的 6 行;行前的 `+` 是新增、`-` 是删除。这正是第 7 个历程 那个 `diff -u` 的格式——连老朋友都不换衣服。将来咱们 review 别人的代码、或者排查「到底是哪次改动引入了问题」,读的都是这种格式。确认无误,再登记、落笔: + +```bash +git add hello.c +git commit -m "hello: add version line" +``` + +```bash +git log --oneline +``` + +```text +51424d7 hello: add version line +7ad7635 hello: journey starts +``` + +户口本(`git log`)从新到旧列着每笔提交。`--oneline` 是紧凑模式;想看每笔的完整改动,`git log -p` 连 diff 一起端上来。 + +## 改坏了?历史来救 + +现在演示这个历程真正的卖点。手滑了——假设咱们把 `main` 函数整个删了,文件一保存,心里一凉。先看事故报告(`--stat` 只看汇总账目:动了哪个文件、几行增减,那串 `+--` 是增删行的条形图,一行对一行): + +```bash +git diff --stat +``` + +```text + hello.c | 3 +-- + 1 file changed, 1 insertion(+), 2 deletions(-) +``` + +工作目录和户口本对不上了。但注意:**户口本里的那笔提交完好无损**——git 的提交是不可变的历史,咱们在工作目录里怎么折腾都伤不到它。所以救回是一行命令的事: + +```bash +git restore hello.c +``` + +它成功时和 diff 一样,一言不发——沉默即恢复。配对脚本会用 `grep` 验证 `journey: v0.2` 那行确实回来了,再替它补一句「hello.c 已恢复,文件内容和最近一次提交一致」。 + +`git restore` 把文件恢复到最近一次提交的样子。这就是「记录」的含金量:第 3 个历程 咱们靠 gdb 救回了程序的逻辑,这个历程咱们多了第二重保险——**任何提交过的状态,永远可以回去**。`git log` 里任何一个指纹,都是一台时光机。 + +## 分支:在不打扰主线的地方折腾 + +想改 README,又怕改坏主线?开条分支: + +```bash +git checkout -b polish-readme +``` + +```text +Switched to a new branch 'polish-readme' +``` + +分支不是「复制一份代码」,只是一个**可以移动的书签**:咱们现在在 `polish-readme` 这枚书签上提交,主线那边风平浪静。在这里把 README 的状态行改成「串口输出已验证 diff 一致」,提交;然后切回主线: + +```bash +git checkout main +``` + +在主线上,**同一个位置**,把那行改成另一个说法——「串口输出已捕获,连 `\r\n` 都是亲手发的」,提交。好了,炸药埋好了:两边动了同一行。 + +## 合并:冲突,以及它的解法 + +```bash +git merge polish-readme +``` + +```text +Auto-merging README.md +CONFLICT (content): Merge conflict in README.md +Automatic merge failed; fix conflicts and then commit the result. +``` + +**CONFLICT**——这是新手最怕的字眼,但请把它重新理解为一件好事:git 把能自动合并的都合并了,唯独「两边对同一行给出了不同意见」的地方,它**不敢替咱们做主**,于是把裁决权连同现场一起交给咱们。看现场: + +```bash +git status +``` + +```text +On branch main +You have unmerged paths. + (fix conflicts and run "git commit") + (use "git merge --abort" to abort the merge) + +Unmerged paths: + (use "git add ..." to mark resolution) + both modified: README.md +``` + +打开 README.md,git 在冲突处留了标记: + +```text +<<<<<<< HEAD +- 第 7 个历程:串口输出已捕获,连 \r\n 都是亲手发的 +======= +- 第 7 个历程:串口输出已验证 diff 一致 +>>>>>>> polish-readme +``` + +`<<<<<<<` 到 `=======` 是咱们这边的(HEAD 是 git 给「咱们此刻站在哪」起的名字,现在指着当前分支),`=======` 到 `>>>>>>>` 是对方的。解法永远是同一个:**动手编辑,留下咱们认为对的最终样子,删掉所有标记**。咱们裁决成一句更准确的: + +```markdown +# box —— 一个程序的一生 + +主线实验仓:EmbedBox journey 的动手记录。 + +## 状态 + +- 第 7 个历程:串口输出已捕获,且与 expected-serial.txt 逐字节一致 +``` + +然后告诉 git 裁决完毕,收尾: + +```bash +git add README.md +git commit -m "merge polish-readme: pick the precise wording" +``` + +顺手打个里程碑——tag 是牢牢锚在历史上的刻度,以后 `git checkout v0.1-journey` 可以随时回到这个精确时刻,发版、留档、交作业都靠它: + +```bash +git tag v0.1-journey +git tag +``` + +```text +v0.1-journey +``` + +## README:协作的门面 + +这个历程的另一半主角是 README 本身。咱们大概注意到,这份 README 用的是 Markdown——`#` 是标题、`-` 是列表、反引号是行内代码。为什么值得学?因为**开源世界的门面全是它写的**:GitHub 的项目首页、Issue、Pull Request、代码里的文档,全是 Markdown。一份好 README 至少回答四件事:这是什么、怎么跑、依赖什么、结果在哪。上面那份小 README 已经是个最小样板。 + +至于「交作业」的完整形态——把本地仓库推上 GitHub、开分支、发 Pull Request——流程就是今天这套动作加一个 `git push` 和网页上的两次点击,GitHub 的官方文档([Hello World 教程](https://docs.github.com/zh/get-started/start-your-journey/hello-world))二十分钟能走完。等咱们在真实仓库提第一个 PR 时,会发现冲突解决这一步,咱们已经亲手做过。 + +## 这个历程咱们带走了什么 + +日常闭环七件套:`status` / `add` / `commit` / `diff` / `log` / `checkout -b` / `merge`;两道保险:提交不可变(`restore` 随时可回)与 tag(锚定里程碑);以及一份不再恐惧 CONFLICT 的心态——那不是事故,是 git 在请咱们签字。 + +## 下一站 + +记录有了,证据有了。但日常工作的椅子还不舒服:编辑器看不懂咱们的代码,满屏红线,跳转失灵。最后一个历程,把前面所有工具接进 VS Code——顺便咱们会发现,那枚第 5 个历程 埋下的 `compile_commands.json`,就是为这一刻准备的。 diff --git a/tutorial/journey/08-vscode.md b/tutorial/journey/08-vscode.md new file mode 100644 index 0000000..9fea7ba --- /dev/null +++ b/tutorial/journey/08-vscode.md @@ -0,0 +1,102 @@ +--- +title: 第 9 个历程 · 编辑器接线:让 VS Code 认识咱们的工具链 +order: 8 +verify: scripts/journey/08-vscode.sh +tier: manual +verified-on: 机械部分(配置存在性/JSON 合法性/任务链解析)由脚本验证;编辑器交互项见正文清单,待首次人工走查后回填 +--- + +# 第 9 个历程 · 编辑器接线:让 VS Code 认识咱们的工具链 + +这个历程**不教编辑器**。VS Code 的教程满山遍野,不缺这一本。它教的是一件小得多也重要得多的事:**把前八个历程学会的工具——gcc、CMake、gdb——接进编辑器**,让「按一个键」背后跑的还是咱们认识的那几条命令。工具永远比 IDE(集成开发环境——把编辑器、构建、调试装进同一个壳的软件,VS Code、Keil、STM32CubeIDE 都是)重要,这是咱们在第 1 个历程 就立下的世界观;这个历程只是让世界观过得舒服点。 + +编辑器交互没法无人值守重放,所以本历程是全书唯一 `tier: manual` 的一个历程——配对脚本负责机械部分(配置文件存在、JSON 合法、任务链完整),真正的「红线消失、断点命中」需要咱们亲手走一遍,正文末有一张清单。 + +## 病症:满屏红线 + +拿第 5 个历程 的 CMake 工程用 VS Code 打开——命令是在 WSL 里敲 `code .`(用 VS Code 打开当前目录;Windows 用户先装 Remote-WSL 扩展,让 Windows 上的 VS Code 隔着一条缝操作 WSL 里的文件与终端)——多半会看到熟悉的病症:代码能编译,编辑器却满屏红线,`#include "util.h"` 报「找不到头文件」,跳转到定义时灵时不灵。原因一句话就能说清:**IntelliSense 不是编译器,它只是在一遍遍猜咱们的编译视角**——猜错了视角,猜出来的世界自然是错的。而猜,本可以不必猜。 + +## 第一根线:compile_commands.json + +还记得第 5 个历程 里那句 `set(CMAKE_EXPORT_COMPILE_COMMANDS ON)` 吗?当时说它在 `build/` 里留了一枚「给编辑器的名片」。现在把名片递过去——在工程根目录的 `.vscode/settings.json` 里: + +```json +{ + "C_Cpp.default.compileCommands": "${workspaceFolder}/build/compile_commands.json", + "files.associations": { + "*.h": "c" + }, + "editor.insertSpaces": true, + "editor.tabSize": 4 +} +``` + +关键的只有第一行:告诉 C/C++ 扩展,**每个文件怎么编译,以这份 JSON 清单为准**——哪个编译器、什么标准、哪些 include 路径,全部来自构建系统的真实视角,不再靠猜。`${workspaceFolder}` 是个占位符,展开成「当前打开的工程根目录」,和 shell 里 `./` 指脚边是同一种亲切。红线通常在这一行落笔后当场消退;`greet` 跳到定义、悬停看签名,也都顺了。其余三行是舒适度配置(把 `.h` 识破成 C 而不是 C++,空格缩进四格)。 + +需要装的东西就一件:C/C++ 扩展(ms-vscode.cpptools,扩展面板搜 `ms-vscode.cpptools` 装第一个就是)。Windows 用户在 Remote-WSL 模式下,扩展要装在 **WSL 侧**(扩展面板会分区提示),这一点装错了是经典坑。 + +## 第二根线:一键构建 + +编辑器的「运行」按钮背后应该是咱们自己的构建命令。`.vscode/tasks.json`: + +```json +{ + "version": "2.0.0", + "tasks": [ + { + "label": "cmake-build", + "type": "shell", + "command": "cmake --build build", + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": ["$gcc"] + } + ] +} +``` + +`command` 一栏就是咱们在第 5 个历程 敲过的那条 `cmake --build build`,一个字没改——现在它绑在了 `Ctrl+Shift+B` 上。`problemMatcher` 让 gcc 的报错变成编辑器里可点击跳转的条目。**IDE 的构建按钮不是魔法,是咱们自己那条命令的马甲**;理解这一点的人,换任何 IDE 都能在五分钟内配好构建。 + +## 第三根线:F5 就是 gdb + +最后把第 3 个历程 的 gdb 接到 F5 上。`.vscode/launch.json`: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "(gdb) hello", + "type": "cppdbg", + "request": "launch", + "program": "${workspaceFolder}/build/hello", + "args": [], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [], + "externalConsole": false, + "MIMode": "gdb", + "preLaunchTask": "cmake-build" + } + ] +} +``` + +逐行认亲:`MIMode: gdb`——调试器就是第 3 个历程 那位,断点、单步、变量窗,底下全是同一套 gdb;`preLaunchTask` 指回上一节的 `cmake-build`,F5 = 先构建、再调试;`program` 指向第 5 个历程 的产物 `build/hello`;`cwd` 是程序启动时所在的目录,相当于先替它 `cd` 过去。在 `main.c` 的 `greet("EmbedBox")` 那行点一下行号左侧设断点,按 F5——构建日志滚过,程序停在断点上,单步走进 `greet`,参数 `who` 的值在变量窗里排排坐。**咱们在第 3 个历程 用命令行做过的一切,现在有了图形界面,但没有一层魔法。** + +再往前看一步:`launch.json` 里再配一个 `target remote`(或装上 Cortex-Debug 扩展指向 OpenOCD),F5 就能调试第 7 个历程 那样的板子——接线方式和咱们已经会的完全一致。 + +## 收束全书:体检,然后出发 + +三根线接完,编辑器认识的不再是「一堆文本」,而是**咱们的**工具链。这也是全书的收束时刻。回望一遍咱们走过来的路:确认机器活着(第 1 个历程),看程序出生、拆开它的肚子(第 2 个历程),治好一场 printf 够不到的病(第 3 个历程),让它长大并学会记账(第 4/5 个历程),搬去另一个架构(第 6 个历程),在没有操作系统、没有屏幕的机器上亲口说话、留下证据(第 7 个历程),把旅程记录成可回溯的历史(第 8 个历程),最后把这一切接进日常的椅子(第 9 个历程)。 + +**从 clean clone 到可追溯的串口证据——这条 community 计划书里的主线,咱们已经完整走通。** 剩下的问题只有一个:接下来去哪。 + +分流口就在眼前:想把语言基本功打深, + +去 [C-Journey](https://github.com/Awesome-Embedded-Learning-Studio/C-Journey)(C 语言承重墙)和 [Tutorial_AwesomeModernCPP](https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP)(现代 C++) + +想理解程序之上的系统,去 [PenguinLab](https://github.com/Awesome-Embedded-Learning-Studio/PenguinLab)(Linux 内核实验)和 [Tutorial_FreeRTOS](https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_FreeRTOS) + +想上真硬件,去 [ST-Forge](https://github.com/Awesome-Embedded-Learning-Studio/ST-Forge)(STM32 主教学线——咱们的 Cortex-M 经验直接复用)和 [imx-forge](https://github.com/Awesome-Embedded-Learning-Studio/imx-forge)(Embedded Linux 入口)。 + +无论去哪,第一件事仍然是第 1 个历程 那份体检——工具永远比 IDE 重要,旅程永远从确认机器活着开始。 diff --git a/tutorial/journey/91-lab-safety.md b/tutorial/journey/91-lab-safety.md new file mode 100644 index 0000000..061629b --- /dev/null +++ b/tutorial/journey/91-lab-safety.md @@ -0,0 +1,38 @@ +--- +title: 尾声 · 实验安全:别把板子和自己搞坏 +order: 91 +tier: manual +verified-on: 常识性内容;具体器件的容忍值以数据手册为准 +--- + +# 尾声 · 实验安全:别把板子和自己搞坏 + +这是全书最短、也最不该跳过的一篇。前面九个历程,坏掉的程序可以 `git restore`,坏掉的虚拟机可以重开;真硬件坏了就是坏了,烧的是钱,少数情况下还会咬人。这一篇只讲「恰好够用」的安全底线——想深入电路与测量的原理,去 [Tutorial_AwesomeHardware](https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeHardware),那是硬件承重墙,这里只发安全帽。 + +## 电压:3.3 与 5 的恩怨 + +嵌入式小板的常见逻辑电平是 3.3V,传感器模块五块钱的和五十块钱的区别常在「是否容忍 5V」。**把 5V 接进只认 3.3V 的引脚,是最常见的烧板方式**,而且死状安静——没有火花,没有烟,只是那个引脚从此再也不响应。底线三条:接任何引脚前,确认双方电平(查数据手册,不是查记忆);电平不匹配就用电平转换模块,几毛钱的事;不确定时,先用下一节的万用表量,而不是用引脚试。 + +## 共地:回路的地基 + +第 7 个历程 咱们给程序分配了住址;电路的世界里,**所有参与通信的设备必须共享同一个地**(GND 连 GND)——电流要有回路,「地」就是那条回程路。USB-TTL 和板子之间只接 TX/RX 不接 GND,收到的会是乱码或寂静,这是新手第二大经典事故(第一大见上节)。测量同理:万用表、示波器的地线夹,夹的都是和被测电路共地的点。 + +## 静电:看不见的那一掌 + +秋冬干燥,咱们摸门把手会被电一下——那一下的量级,足够 CMOS 芯片的栅极记住一辈子(栅极是 CMOS 晶体管上那层极薄的「门」,静电压一穿,门就废了)。拿板子前,摸一下接地的金属(暖气片、机箱裸露金属)放掉身上的静电;有条件,防静电手环和防静电垫当然更好。板子不用时放进防静电袋,而不是随手放在化纤桌布上。 + +## 万用表:三招防身 + +一块几十块的万用表,新手期只需要三招。**电压档**:并联着量,量之前确认档位和量程——量 3.3V 引脚读数 5V,说明供电就有问题,先断电查电源。**通断档**(蜂鸣档):**断电后**用来验证「这两点到底通没通」——查虚焊、查短路,接线和上电前的最后检查全靠它。**电流档**:必须**串联**进回路,而且用完立刻换回电压档——电流档内阻近零,下次直接并联到电源上就是一支一次性保险丝熔断器(运气差时是笔更贵的学费)。 + +## 上电前三问 + +接好线、手放在电源开关上时,过一遍: + +一,**电压对吗?**——供电是 5V 还是 3.3V,板子上有没有稳压,引脚容忍范围是多少。二,**极性对吗?**——VCC(供电正极的名字)和 GND 有没有接反(接反是第三大烧板原因;很多板子的反接是直接短路)。三,**共地了吗?**——所有要通信的设备,GND 连在一起了吗。 + +三问都过,再上电。这三秒钟,是嵌入式最便宜的保险。 + +## 出发 + +安全帽发完了。好了!我说,祝各位的嵌入式学习之旅一帆风顺! diff --git a/tutorial/journey/assets/00/cmd.png b/tutorial/journey/assets/00/cmd.png new file mode 100644 index 0000000..87fc236 Binary files /dev/null and b/tutorial/journey/assets/00/cmd.png differ diff --git a/tutorial/journey/assets/00/powershell.png b/tutorial/journey/assets/00/powershell.png new file mode 100644 index 0000000..757fd48 Binary files /dev/null and b/tutorial/journey/assets/00/powershell.png differ diff --git a/tutorial/journey/assets/00/vscode-cmdline.png b/tutorial/journey/assets/00/vscode-cmdline.png new file mode 100644 index 0000000..135fab3 Binary files /dev/null and b/tutorial/journey/assets/00/vscode-cmdline.png differ diff --git a/tutorial/journey/assets/06/real_output.png b/tutorial/journey/assets/06/real_output.png new file mode 100644 index 0000000..5aacd61 Binary files /dev/null and b/tutorial/journey/assets/06/real_output.png differ diff --git a/tutorial/journey/index.md b/tutorial/journey/index.md new file mode 100644 index 0000000..4c8a01f --- /dev/null +++ b/tutorial/journey/index.md @@ -0,0 +1,25 @@ +--- +title: 主线 · 一个程序的一生 +order: 1 +--- + +# 主线 · 一个程序的一生 + +这是 EmbedBox 的**首刷推荐路线**。全书只有一个主角——一个会打招呼的小程序:它从一行 `hello.c` 开始,被解剖、生病、长大、工程化、搬家、进入一台没有屏幕的机器、开口说话、被记录、被编辑器认领。每一个历程,都是被一个具体的事故逼着学会一件新工具。 + +补课与查用的朋友,各工具的系统内容在参考篇(建设中);从零开始的朋友,**从这里走**。 + +| 历程 | 故事 | 状态 | +|---|---|---| +| [第 1 个历程 · 造机器](./00-env-check.md) | 环境体检:clone 下来先确认机器活着 | 完成 | +| [第 2 个历程 · 造程序](./01-elf.md) | 看着源码一步步变成 ELF,再把它拆开看 | 完成 | +| [第 3 个历程 · 治病](./02-gdb.md) | 程序被(笔者)弄坏了,printf 够不到病灶,GDB 出场 | 完成 | +| [第 4 个历程 · 程序长大](./03-make.md) | 改了头文件忘重编,炸出 undefined reference;让 make 接管记账 | 完成 | +| [第 5 个历程 · 工程化](./04-cmake.md) | 库与应用分离,CMake 产出 compile_commands.json | 完成 | +| [第 6 个历程 · 搬家](./05-cross.md) | 宿主机二进制目标机跑不了,交叉编译 | 完成 | +| [第 7 个历程 · 没有屏幕的机器](./06-qemu-uart.md) | QEMU 给身体,串口开口说话,证据落袋 | 完成 | +| [第 8 个历程 · 记录旅程](./07-git.md) | Git 与 Markdown:让旅程可复现、可交付 | 完成 | +| [第 9 个历程 · 编辑器接线](./08-vscode.md) | 把工具链接进 VS Code | 完成 | +| [尾声 · 实验安全](./91-lab-safety.md) | 3.3V/5V、共地、静电、万用表三招(硬件场景的保命常识) | 完成 | + +> 这一卷的每章正文命令,都被 `scripts/journey/` 里的配对脚本在 CI 中逐字重放——咱们看到的输出是真跑出来的,不是编的。第 9 个历程 的编辑器交互项与尾声的安全常识为 manual 级,由人工走查声明背书。