C 语言初识:从 Hello World 走进 C 的世界

本章不是 C 语法速成,而是从一段最简单的 Hello, World! 程序出发,
顺着代码 → 工具 → 编译 → 链接 → 运行这条链路,把 C 语言开发涉及的
所有环节都过一遍。读完后你应该能在 Linux 终端独立完成:
编辑代码、编译、运行、调试。


一、示例代码

新建一个文件 hello.c

/* hello.c - 第一个 C 程序 */
#include <stdio.h>

int main(void)
{
    printf("Hello, World!\n");
    return 0;
}

只有 5 行业务代码 + 1 行头文件引用,但它已经是一个完整可执行的程序。


二、逐行解释(注释版代码)

把所有解释直接嵌入到代码注释里,看代码 = 看文档。

/*==============================================================================
 * hello.c —— 第一个 C 程序
 *
 * C 语言有两种注释(都不会被编译,仅供阅读):
 *   1. /* ... * /   块注释,可跨行(本注释就是)
 *   2. // ...      行注释(C99 起支持)
 *============================================================================*/

// #include 是「预处理指令」,不是普通语句
// 编译之前,预处理器会把 <stdio.h> 的全部内容
// 原封不动地复制粘贴到这一行
// <> 表示去系统头文件目录找(通常 /usr/include)
// 双引号 "" 则先在当前目录找
#include <stdio.h>             // stdio.h = Standard I/O
                              // 声明了 printf / scanf / fopen 等函数

// int main(void) —— 程序的入口函数
//   - 操作系统加载完程序后,第一个执行的就是 main
//   - void 表示「这个函数不接受任何参数」
//   - int  表示「这个函数返回一个整数」
//   - 约定:return 0 表示正常退出;非 0 表示异常退出(父进程 Shell 能拿到)
int main(void)
{                              // 花括号 { } 划定 main 的作用域
    printf("Hello, World!\n"); // printf = print formatted
                              // 把字符串打印到「标准输出 stdout」(默认是终端)
                              // \n 是转义字符 = 换行
                              // "..." 内是字符串字面量

    return 0;                  // 把 0 作为 main 的返回值,交给操作系统
}                              // main 函数结束

执行流程对照:

程序启动
   ↓
操作系统调用 main()
   ↓
printf("Hello, World!\n")  →  屏幕显示 "Hello, World!" 并换行
   ↓
return 0                   →  操作系统收到退出码 0
   ↓
进程结束

三、工具链介绍

写一个 C 程序至少需要两类工具:编辑器 + 编译器

1. 编辑器(写代码)

编辑器 特点 适合场景
VS Code 现代、插件丰富、跨平台 入门首选
Vim / Neovim 终端内运行、纯键盘、极度高效 嵌入式开发(远程服务器)
Emacs 可扩展性极强 老牌硬核玩家
Sublime Text 轻量、启动快 编辑单个文件
gedit / Notepad++ 极简 临时改改

嵌入式 Linux 推荐:VS Code + Remote SSH 插件,或者直接用 Vim/nano。

2. 编译器(翻译代码)

主流 C 编译器:

编译器 来源 默认系统
GCC(GNU Compiler Collection) GNU Linux、MinGW
Clang LLVM macOS、FreeBSD
MSVC(Microsoft Visual C++) 微软 Windows
Arm Compiler / arm-none-eabi-gcc ARM 嵌入式 MCU

嵌入式 Linux 交叉编译aarch64-linux-gnu-gccarm-linux-gnueabihf-gcc 等。

3. 其他常用工具

工具 用途
make / cmake 自动化构建(多文件项目)
GDB 调试器(断点、单步)
ldd 查看可执行文件的动态库依赖
objdump / readelf 反汇编 / 查看 ELF 结构
strace 跟踪进程的系统调用
nm / ldd / file 符号、依赖、类型查看

四、编译环境

不同操作系统有不同的「标准」编译环境:

系统 编译器 C 标准库 典型工具链
Linux GCC 或 Clang glibc(或 musl) gcc, make, gdb
macOS Clang(Apple 改版) Apple libc clang, lldb
Windows MSVC 或 MinGW MSVCRT / ucrt cl.exe / gcc

嵌入式 Linux 通常用交叉编译工具链:

# 主机(x86)→ 目标(ARM)
apt install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu

# 编译时指定
aarch64-linux-gnu-gcc -o hello hello.c

五、编译命令

打开终端,进入 hello.c 所在目录:

gcc -o hello hello.c

这行命令做了什么?
- gcc:调用 GCC 编译器
- -o hello:指定输出文件名为 hello(不指定则默认为 a.out
- hello.c:源文件

常用参数

gcc -Wall -Wextra -O2 -g -o hello hello.c
参数 作用
-Wall 开启常用警告
-Wextra 开启更多警告
-O2 优化级别(生成更快代码)
-O0 不优化(调试用)
-g 生成调试符号(GDB 需要)
-std=c11 指定 C 标准(C89/C99/C11/C17)
-I<dir> 添加头文件搜索目录
-L<dir> 添加库文件搜索目录
-l<name> 链接库(如 -lm 链接数学库)
-D<name> 宏定义(如 -DDEBUG
-E 只预处理(生成 .i 文件)
-S 只编译到汇编(生成 .s 文件)
-c 只编译不链接(生成 .o 文件)

六、编译链接过程

C 程序从源码到可执行文件经历四个阶段

hello.c  ──预处理──>  hello.i  ──编译──>  hello.s  ──汇编──>  hello.o  ──链接──>  hello
   │                    │                  │                  │                │
源码                  展开宏              汇编代码          目标文件       可执行文件
                   包含头文件          (人可读)        (机器码)
                   删注释

阶段 1:预处理(Preprocessing)

gcc -E hello.c -o hello.i

预处理器做的事:
- #include 的文件原样插入
- #define 的宏被替换
- 条件编译 #if/#ifdef 被求值
- 注释被删除
- 行号标记 # lineno "file" 加入(编译报错时定位用)

输出 hello.i 通常有几万行(因为 stdio.h 展开后很大)。

阶段 2:编译(Compilation)

gcc -S hello.i -o hello.s

编译器(真正的 cc1)把预处理后的 C 代码翻译成汇编语言
- 检查语法、类型
- 做优化(-O2
- 输出汇编文件 hello.s

人可以读懂 hello.s

    .file   "hello.c"
    .section .rodata
.LC0:
    .string "Hello, World!"
    .text
    .globl  main
    .type   main, @function
main:
    pushq   %rbp
    movq    %rsp, %rbp
    leaq    .LC0(%rip), %rax
    movl    $0, %esi
    movq    %rax, %rdi
    call    puts@PLT
    movl    $0, %eax
    popq    %rbp
    ret

阶段 3:汇编(Assembly)

gcc -c hello.s -o hello.o

汇编器(as)把汇编代码翻译成机器码,生成目标文件 hello.o
- 已经是二进制,但还不能执行(缺库函数 printf 的实现)

阶段 4:链接(Linking)

gcc hello.o -o hello

链接器(ld)做的事:
- 把多个 .o 合并
- 解析未定义符号(如 printf
- 从标准库(glibc)找到 printf 的实现
- 把所有代码段、数据段排好地址
- 生成最终的可执行文件

可以用 ldd hello 查看依赖:

$ ldd hello
    linux-vdso.so.1 (0x00007ffd12345000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6     glibc
    /lib64/ld-linux-x86-64.so.2

静态链接 vs 动态链接
- 默认是动态链接hello 运行时需要 libc.so.6
- 加 -static 可静态链接(文件大很多,但可独立运行)
bash gcc -static -o hello hello.c

一行命令完成四阶段

gcc -o hello hello.c

GCC 默认一次性完成全部四步。如果想看中间产物:

gcc -save-temps -o hello hello.c
ls
# hello  hello.c  hello.i  hello.o  hello.s

七、编译结果

$ ls -lh hello
-rwxr-xr-x 1 user user 16K 7月 21 10:00 hello

文件类型:

$ file hello
hello: ELF 64-bit LSB pie executable, x86-64, ...

ELF(Executable and Linkable Format) 格式(Linux 上)。

ELF 文件结构(粗略):

┌──────────────┐
│   ELF 头部    │  → 元信息(架构、入口点等)
├──────────────┤
│   程序头表    │  → 告诉系统怎么加载
├──────────────┤
│   .text      │  → 代码(main 等)
│   .rodata    │  → 只读数据("Hello, World!" 字符串)
│   .data      │  → 已初始化全局变量
│   .bss       │  → 未初始化全局变量(运行时清零)
├──────────────┤
│   .symtab    │  → 符号表(调试用)
│   .strtab    │  → 字符串表
│   .debug_*    │  → 调试信息(-g 时生成)
└──────────────┘

可以用 readelfobjdump 查看详情:

readelf -h hello      # 头部
objdump -d hello      # 反汇编
nm hello              # 符号表

八、运行环境

要让 hello 真正运行起来,需要:

  1. 操作系统:Linux 内核负责加载、调度进程
  2. C 标准库/lib/x86_64-linux-gnu/libc.so.6(即 glibc)
  3. 动态链接器/lib64/ld-linux-x86-64.so.2
  4. 终端:标准输出要有个目的地(通常是 tty)

实际运行命令:

$ ./hello
Hello, World!
$

$ 是 Shell 提示符,意思是「程序已退出」。


九、运行结果

成功运行时:
- 屏幕上输出 Hello, World! 后跟一个换行
- 程序退出码为 0(正常)

可以用 $? 查看退出码:

$ ./hello
Hello, World!
$ echo $?
0

如果 main 返回非 0:

return 1;   // 出错了
$ ./hello
$ echo $?
1

Shell 脚本会据此判断成功/失败。


十、运行时系统执行过程(深入)

图例:⭐ = 基础(嵌入式工程师必须知道) | 🔥 = 进阶(内核/系统程序员才需要深究)

本节按完整时间线列出所有阶段。标 ⭐ 的部分先掌握,标 🔥 的部分可在需要时再深入。

阶段 A:Shell 解析命令行 ⭐

1. Shell 读取键盘输入
- 你按回车,Shell 从 tty 读到一行字符串 ./hello
- Shell 用自己的语法解析器拆分成命令 + 参数

2. Shell 在 PATH 中查找 hello
- 顺序遍历 /usr/local/bin/usr/bin/bin...
- 找到可执行文件,拿到完整路径

3. Shell 检查文件权限
- 验证 ./hello 有可执行位(-rwxr-xr-x
- 当前用户对它有 x 权限
- 不是目录(否则报错 "Is a directory")

阶段 B:Shell 创建子进程 ⭐

4. Shell 调用 fork()
- Shell 进程调用 fork(),把自己完整复制一份
- 现在有两个几乎一样的进程:父(Shell)和子(待运行 hello)
- 复制的内容:代码、数据、堆、栈、文件描述符表、信号处理...
- 写时复制(COW):实际内存页暂不复制,子进程改时才复制

5. Shell 在父进程中调用 waitpid()
- Shell 阻塞自己,等待子进程结束
- 这就是为什么 Shell 提示符要等命令执行完才出现

阶段 C:子进程加载可执行文件(execve) ⭐

6. 子进程调用 execve("./hello", argv, envp)
- 替换:子进程的代码段、数据段、堆、栈全部丢弃
- 保留:进程 ID(PID)、打开的文件描述符、信号掩码

7. 内核的 ELF 加载器(fs/binfmt_elf.c)开始工作

8. 内核读取 ELF 头部 🔥
- 验证魔数 0x7f 'E' 'L' 'F'
- 确认架构(x86_64 / aarch64 / arm)
- 读取程序头表(Program Headers)数量和位置
- 找到入口点(Entry Point)地址

9. 内核创建新的虚拟地址空间 🔥
- 分配新的页表(mm_struct)
- 清空旧的虚拟内存映射

10. 内核按段(Segment)映射
- PT_LOAD 段被映射到虚拟地址:
- .text(代码)→ 只读 + 可执行
- .rodata(字符串字面量)→ 只读
- .data(已初始化全局变量)→ 可读写
- .bss(未初始化全局变量)→ 可读写,初始全零
- 使用 mmap() 系统调用把文件段映射进虚拟内存

11. 内核处理动态链接器
- 如果是动态链接 ELF,找到 .interp 段指定的 ld-linux.so
- 把 ld-linux.sommap 进进程地址空间

12. 内核设置用户栈 🔥
- 在地址空间高端分配一页作为初始栈
- 把 argvenvp、auxv(辅助向量)压栈
- ELF auxv 包含:可执行文件路径、PHDR 地址、随机化种子等

13. 内核关闭 O_CLOEXEC 之外的文件描述符
- 标记 CLOEXEC 的 fd 会被关闭
- 其余保留(stdin/stdout/stderr 通常保留)

14. 内核将进程状态设为"就绪"
- 修改进程 task_struct
- 把控制权交还给进程调度器

阶段 D:动态链接器运行 🔥

这一段标 🔥 是因为大多数应用开发者一辈子不用碰。但嵌入式系统工程师需要知道。

15. CPU 从入口点开始执行
- 入口点不是 main,而是 _start(crt0 提供)
- 或动态链接情况下,先跳到 ld-linux.so 的入口

16. 动态链接器(ld-linux)解析依赖 🔥
- 读取 hello.dynamic
- 解析 DT_NEEDED 字段:libc.so.6
- 用 mmap 把每个依赖库映射进进程

17. 动态链接器做符号解析 🔥
- 遍历 hello 的重定位表(.rel.plt.rel.dyn
- 对每个未定义符号(如 printf),在已加载库中查找
- 写入 GOT(Global Offset Table)

18. 动态链接器执行初始化函数 🔥
- 调用每个库的 __attribute__((constructor)) 函数
- C++ 全局对象的构造也在这里执行

19. 动态链接器跳转到程序入口 _start
- 把控制权交给 hello

阶段 E:C 运行时初始化(crt0) 🔥

glibc 的 crt0(crt = C RunTime)。嵌入式裸机工程师会自己写一个简化版。

20. _start 设置栈指针
- 从栈顶读取 argcargvenvp(由内核压栈)
- 调用 __libc_start_main

21. __libc_start_main 初始化 libc 内部 🔥
- 设置 errno
- 初始化 stdin/stdout/stderr 的 FILE 结构
- 初始化 malloc 内存池
- 初始化线程局部存储(TLS)
- 初始化 locale
- 注册 atexit 清理函数

22. __libc_start_main 调用 main(argc, argv, envp)
- 此时用户代码才真正开始执行

阶段 F:执行 main() ⭐

23. main 执行函数序言
asm pushq %rbp movq %rsp, %rbp
- 保存调用者栈帧
- 建立自己的栈帧

24. 调用 printf(实际是 puts,GCC 自动优化)
asm leaq .LC0(%rip), %rax ; rax = "Hello, World!\n" 字符串地址 movl $0, %esi ; 变参占位 movq %rax, %rdi ; 第一参数 = 字符串 call puts@PLT
- GCC 看到只有一个字符串 + \n,自动优化为 puts
- PLT(Procedure Linkage Table)是动态链接跳板

25. 进入 libc 的 puts()
- 拿到字符串指针
- 加锁 stdout(防止多线程竞争)
- 拷贝字符串到 stdout 缓冲区
- 调用底层的 _IO_file_xsputn

26. printf/puts 触发 write() 系统调用
- 把用户态缓冲区数据传给内核
- 系统调用号:x86_64syscall 指令,ARMsvc #0

阶段 G:内核处理 write() 系统调用 🔥

27. CPU 切换到内核态
- syscall 指令触发:
- 保存用户态寄存器到内核栈
- 切换到内核页表
- 跳到 entry_SYSCALL_64(x86)/vector_swi(ARM)

28. 内核系统调用分派
- 读取系统调用号 → 这里是 write
- 查 sys_call_table → 找到 sys_write 函数
- 传入参数:fd, buf, count

29. sys_write 处理
- 检查 fd 合法性(这里是 1 = stdout)
- 找到对应的 struct file(文件描述符指向的文件对象)
- 调用 file->f_op->write()(VFS 抽象层)

30. VFS 层 🔥
- 对于 stdout 这种字符设备,绕过 page cache
- 调用 tty 驱动的 write 方法

31. tty 驱动层
- 把数据写入 tty 的发送缓冲区
- 触发硬件中断
- 串口 / 终端硬件把数据发出

32. 终端接收端
- 终端模拟器(xterm 等)收到数据
- 解析 ANSI 转义序列(\n = 光标移到下一行)
- 通过 X11 / Wayland 协议绘制到屏幕

33. 屏幕上显示 Hello, World!

34. 系统调用返回
- write 返回写入字节数
- CPU 切换回用户态
- 恢复用户态寄存器
- 回到 puts 中继续执行

阶段 H:main 返回与清理 ⭐

35. puts 解锁 stdout

36. puts 返回到 main

37. main 执行 return 0
asm movl $0, %eax ; 返回值 = 0 popq %rbp ; 恢复栈帧 ret ; 跳回调用者(__libc_start_main)

38. __libc_start_main 接住 main 返回值
- 保存为 exit_code = 0
- 调用 exit(exit_code)

39. exit 触发 atexit 清理链 🔥
- 按 LIFO 顺序调用所有 atexit() 注册的函数
- 刷新所有 stdio 缓冲区
- 关闭所有 C 库管理的 FILE*

40. exit 调用 __run_exit_handlers 🔥
- 处理 atexit 注册的函数
- 关闭 on_exit 等注册的清理器

41. 关闭文件描述符 0/1/2(如果 CLOEXEC)

42. 调用 _exit()(或 exit_group() 系统调用)

阶段 I:进程退出 ⭐

43. 内核处理 exit_group 系统调用
- 标记进程为 EXIT_ZOMBIE(僵尸态)
- 释放用户空间内存(mmap 区域)
- 关闭进程打开的所有文件描述符
- 释放 PID 等待父进程回收
- 进程调度器不再调度该进程

44. 父进程 Shell 从 waitpid 返回
- Shell 之前阻塞在 waitpid(pid, &status, 0)
- 系统调用被唤醒,返回子进程 PID 和退出状态
- Shell 读取 status 中的退出码(这里是 0)

45. Shell 打印下一个提示符 $
- 准备读下一条命令

阶段 J:完整的进程地址空间(最终态) 🔥

执行期间,进程地址空间大致如下:

高地址
   0xFFFF_FFFF_FFFF_FFFF  ┐
                          │  内核空间(所有进程共享)
   ...                    │
   0x00007FFF_FFFF_FFFF    │  ← 用户栈边界
                          │
   0x00007FFE_xxxx_xxxx    │  ← 命令行参数、环境变量
                          │  ← (由内核压栈)
                          │
   ...                    │  栈(向下增长)
                          │
   0x00007Fxx_xxxx_xxxx    │  共享库映射区
   (libc.so, ld-linux.so)
                          │
                          │  堆(向上增长)
   0x000055xx_xxxx_xxxx    │  ← hello 主程序 .text .data .bss
                          │
   0x00000000_00000000    ┘  NULL(不可访问)
低地址

阶段汇总表

阶段 主要参与者 步骤范围 重要程度
A. Shell 解析 Shell 1-3 ⭐ 基础
B. Shell fork Shell 4-5 ⭐ 基础
C. 内核加载 内核 ELF 加载器 6-14 ⭐ + 🔥
D. 动态链接 ld-linux 15-19 🔥 进阶
E. C 运行时 crt0 / __libc_start_main 20-22 🔥
F. main 执行 用户代码 23-26 ⭐ 基础
G. 系统调用 内核 VFS / tty 27-34 ⭐ + 🔥
H. main 返回 libc / 用户代码 35-42
I. 进程退出 内核 / Shell 43-45 ⭐ 基础
J. 地址空间 全程 整体视图 🔥

时间线总图

你按回车
  │
  ├─ [A] Shell 解析命令行                    ⭐
  │
  ├─ [B] Shell: fork() + 子进程 waitpid()      ⭐
  │
  ├─ [C] 子进程: execve()
  │     │
  │     ├─ 内核读取 ELF 头部                  ⭐
  │     ├─ 内核创建地址空间 + 页表             🔥
  │     ├─ mmap 各段(.text/.data/.bss/.rodata)⭐
  │     ├─ 映射 ld-linux.so                 ⭐
  │     ├─ 压栈 argv/envp/auxv               🔥
  │     └─ 进程设为就绪                       ⭐
  │
  ├─ [D] ld-linux 动态链接
  │     ├─ 解析 DT_NEEDED                    🔥
  │     ├─ mmap libc.so 等                    🔥
  │     ├─ 符号解析 + 重定位                  🔥
  │     └─ 调用 _start                       ⭐
  │
  ├─ [E] C 运行时初始化
  │     ├─ _start 设置栈                      🔥
  │     ├─ __libc_start_main 初始化 libc       🔥
  │     └─ 调用 main()                        ⭐
  │
  ├─ [F] main 执行
  │     ├─ 函数序言                           ⭐
  │     ├─ printf/puts → write() 系统调用     ⭐
  │     │
  │     │   [G] 内核处理 write()
  │     │     ├─ syscall 切内核态              ⭐
  │     │     ├─ sys_write → VFS              ⭐
  │     │     ├─ tty 驱动                     ⭐
  │     │     └─ 终端显示 Hello, World!        ⭐
  │     │
  │     ├─ puts 返回                          ⭐
  │     └─ return 0                           ⭐
  │
  ├─ [H] 清理
  │     ├─ exit()                             ⭐
  │     ├─ atexit 处理                        🔥
  │     ├─ 刷新 stdio 缓冲区                 ⭐
  │     └─ _exit() 系统调用                   ⭐
  │
  ├─ [I] 内核回收
  │     ├─ 进程变 ZOMBIE                     ⭐
  │     ├─ 释放内存、关闭 fd                 ⭐
  │     └─ Shell: waitpid() 返回              ⭐
  │
  └─ Shell: 打印下一个 $                    ⭐

总耗时:几毫秒(在现代 PC 上)

嵌入式场景的差异

嵌入式 Linux 与桌面 Linux 在这条链路上几乎完全一样,区别只在:

环节 桌面 Linux 嵌入式 Linux
终端 xterm / SSH 串口 / fbterm
显示 X11 / Wayland framebuffer / LCD
Shell bash / zsh busybox sh / msh
glibc musl / uClibc / newlib
文件系统 ext4 / xfs squashfs / jffs2 / ubifs
启动 systemd BusyBox init / systemd

如果跑裸机(无 OS),步骤 A-I 全部不存在,直接是:
1. CPU 上电 → 从 Flash 读第一条指令
2. 跳到 main
3. 直接操作硬件寄存器(无 libc、无 syscall)

这就是为什么嵌入式工程师要学「启动流程」——自己手写没有 OS 时的 _startmain

十一、动手试试

把上面的 hello.c 抄下来,自己跑一遍:

# 1. 编辑
vim hello.c      # 或 vscode hello.c

# 2. 编译
gcc -Wall -Wextra -o hello hello.c

# 3. 查看产物
ls -lh hello
file hello

# 4. 运行
./hello

# 5. 看看中间产物
gcc -save-temps -o hello hello.c
cat hello.i | head -20    # 预处理后
cat hello.s              # 汇编
objdump -d hello.o        # 目标文件反汇编

# 6. 用 GDB 调试
gdb ./hello
(gdb) break main
(gdb) run
(gdb) next
(gdb) step
(gdb) info registers
(gdb) quit

跑完这些命令,C 语言的"Hello World"才算真正掌握。


小结

步骤 工具 输入 输出
编辑 vim / VS Code 键盘 hello.c
预处理 cpp hello.c hello.i
编译 cc1 hello.i hello.s
汇编 as hello.s hello.o
链接 ld hello.o + libc hello(可执行)
加载 内核 hello 进程
执行 CPU + 库 进程 Hello, World!

C 语言的魅力:简单到极致,复杂到无限。从这一行 printf 出发,
你可以一路学到操作系统、编译器、链接器、ELF 格式、系统调用……
这是嵌入式 Linux 工程师的必修基本功。