C foundations / Lesson 08

把程序拆成几个文件,
再停下来观察它。

把上一课的采样计算拆成主程序、头文件和实现文件。亲手编译链接,再用 GDB 在函数入口停下,查看传入的长度和第一个元素。

08 / 多文件与调试建议先完成第 07 课先运行,再下断点
01 / 目标

为什么需要多个文件?

如果每个功能都挤在 main 里,程序变长后就很难定位问题。我们可以给采样计算一个清晰的接口,让主程序调用它;具体算法则放在单独的源文件里。

  • 区分 .h 中的函数声明与 .c 中的函数定义。
  • 理解预处理、编译和链接各解决什么问题。
  • 使用 GCC 编译两个源文件,并运行预期输出。
  • 用 GDB 设置断点、查看参数、运行到函数返回。
本课产出

一个含 main.c、sensor.c、sensor.h 的项目;一份 GDB 调试记录,写下传入的样本数、首项和函数返回值。

02 / 文件结构

先建立一个文件夹

新建一个工作文件夹,把以下三个文件放在同一目录。不要把文件内容拼接在一起:每个代码块对应一个文件名。

sensor-demo/
├── main.c      主程序:准备数据、调用函数、打印结果
├── sensor.h    接口:声明函数和需要的类型
└── sensor.c    实现:计算平均值、判断阈值

例如主程序需要知道 sensor_mean 的参数和返回类型,但无需把计算循环重写一遍。头文件提供声明,源文件提供定义;三份文件保存后一起参与构建。

04 / 实现

第二份文件:sensor.c

保存为 sensor.c。这个文件包含自己的头文件,帮助编译器对照声明与定义。

sensor.c两个函数的实现
#include "sensor.h"

double sensor_mean(const int samples[], size_t count)
{
    if (samples == NULL || count == 0) {
        return 0.0;
    }

    double sum = 0.0;
    for (size_t i = 0; i < count; i++) {
        sum += samples[i];
    }
    return sum / (double)count;
}

int sensor_is_high(double value, double threshold)
{
    return value >= threshold;
}

sensor_mean 先处理空指针或零元素的情况,再遍历样本。这里为了练习约定两种情况返回 0.0;它无法区分“没有样本”和“样本平均值恰好为零”。真实系统可另设状态返回值,不应把这个简化接口当作完整故障处理。

sensor_is_high 比较两个数:大于或等于阈值时返回 1,否则返回 0。函数中的 count 必须由调用方按实际数组长度传入;空指针检查不能证明非空指针一定有效,也不能发现过大的长度。

05 / 调用

第三份文件:main.c

保存为 main.c。这里的样本是固定数据,不需要输入,便于先预测结果。

main.c程序入口
#include <stdio.h>
#include <stddef.h>
#include "sensor.h"

int main(void)
{
    int samples[] = {18, 21, 24, 20};
    size_t count = sizeof samples / sizeof samples[0];
    double mean = sensor_mean(samples, count);
    int high = sensor_is_high(mean, 22.0);

    printf("count=%zu\n", count);
    printf("mean=%.2f C\n", mean);
    printf("status=%s\n", high ? "HIGH" : "NORMAL");
    return 0;
}

平均值是 (18 + 21 + 24 + 20) / 4 = 20.75。由于 20.75 小于阈值 22.0,high 为 0。条件运算符 high ? "HIGH" : "NORMAL" 根据条件选择一个字符串,供 %s 输出。

注意 #include "sensor.h" 使用引号,引用本项目头文件;#include <stdio.h> 引入标准库头文件。它们告诉编译器声明,编译时仍需把包含定义的 sensor.c 一起构建。

06 / 构建

一次命令编译并链接

打开上一课使用的终端,进入三个文件所在目录,并先运行 gcc --version。两种环境命令稍有不同:

Windows · MSYS2 UCRT64

编译并运行

gcc -std=c11 -Wall -Wextra -g -O0 main.c sensor.c -o sensor_demo.exe
./sensor_demo.exe
Ubuntu

编译并运行

gcc -std=c11 -Wall -Wextra -g -O0 main.c sensor.c -o sensor_demo
./sensor_demo

-g 生成调试信息,-O0 在本例中关闭优化,便于逐行观察;-Wall -Wextra 开启常用警告。成功输出:

count=4
mean=20.75 C
status=NORMAL

修改任一源文件后,先保存并重新执行编译命令,再运行新生成的程序。调试时也要使用重新编译后的可执行文件。

07 / 构建过程

编译成功后,链接仍有工作要做

可以把“一次命令”理解为几个阶段:预处理器展开头文件;编译器将每个 .c 处理为目标代码;链接器把主程序对函数的引用与另一个文件中的定义连接起来。下图是帮助理解的简化流程:

main.c + sensor.h  ──编译──> main.o    ┐
                                       ├─链接─> sensor_demo
sensor.c + sensor.h ─编译──> sensor.o  ┘

想观察中间文件,可在同一目录依次执行:

gcc -std=c11 -Wall -Wextra -g -O0 -c main.c
gcc -std=c11 -Wall -Wextra -g -O0 -c sensor.c
gcc main.o sensor.o -o sensor_demo

Windows 的 GCC 通常生成 sensor_demo.exe,也可以把最后一条的输出名明确写为 sensor_demo.exe;Ubuntu 使用 ./sensor_demo 运行。-c 到生成目标文件为止,不进行最终链接。头文件在本例中被源文件包含,不需要把 sensor.h 单独当作一个源文件链接。

只编译 main.c 即使能看到头文件声明,链接时仍需要找到函数定义;若漏掉 sensor.c 或对应目标文件,就会出现类似 undefined reference 的链接错误。

08 / 调试

让程序在函数入口停下来

先在终端运行 gdb --version。如果命令不存在,Windows 的 MSYS2 UCRT64 终端可安装 pacman -S mingw-w64-ucrt-x86_64-gdb;Ubuntu 可用 sudo apt update 后执行 sudo apt install gdb。以系统包管理器与当前环境实际提示为准。

确保上一节的可执行文件用 -g -O0 重新生成。在终端启动调试器:

Windows · MSYS2 UCRT64
gdb ./sensor_demo.exe
Ubuntu
gdb ./sensor_demo

进入后出现 (gdb) 提示符。以下每行是一个命令,提示符本身不需要输入:

(gdb) break sensor_mean
(gdb) run
(gdb) print count
(gdb) print samples[0]
(gdb) finish
(gdb) continue
(gdb) quit
break sensor_mean
在函数入口设断点。实际源码行号和地址随环境变化。
run
从程序开头运行,遇到断点暂停;此时函数参数已经传入。
print count / print samples[0]
应分别看到 4 和 18。GDB 的结果编号如 $1、$2 因会话不同而变化。
finish / continue / quit
finish 运行到当前函数返回;返回值应为 20.75。continue 继续到程序结束,再用 quit 退出 GDB。

如果想逐行观察,可在断点处输入 next;它执行当前行并停在下一位置。变量只有执行到初始化之后才适合观察其初始值。例如 sum 的声明执行前,不应把它显示出的内容当作有效结果。不同 GDB 版本可能显示不同的附加提示。

09 / 接口边界

函数声明也是双方约定

数组传参时,函数拿到地址和调用方给的数量。主程序在数组定义所在作用域计算 sizeof samples / sizeof samples[0],得到实际的 4;sensor_mean 用 i < count 控制访问。

若调用者把数量写成 5,而数组仍只有 4 项,函数没有足够信息自动恢复正确长度;继续读取就会越界。空指针和零长度分支只是基本检查。真正的可复用模块还需要清晰的错误状态、数据类型范围、缓冲区约定和测试。

联系 STM32

以后采集 ADC 或串口数据时,同样需要记录缓冲区容量、已有数据量和模块接口。电脑上的例程帮助理解组织方式;板卡上的启动文件、外设和工具链还需要根据具体项目学习。

10 / 排错

把错误分到正确阶段

现象可能原因处理
sensor.h: No such file or directory文件名拼错或不在当前工作目录。用 ls 查看三个文件,核对拼写和大小写。
undefined reference to sensor_mean已看到声明,但链接时没有对应定义。检查 sensor.c 是否参与命令,核对名称和参数声明。
重复定义函数不小心同时包含源文件又单独编译该源文件。在本例中包含 sensor.h,并单独编译 sensor.c。
GDB 看不到源码或变量可执行文件未带调试信息,或源文件路径不同。在当前目录以 -g -O0 重新构建,并确认使用了新程序。
修改后仍看到旧结果源码已保存但没有重新编译,或运行了其他目录的程序。核对目录、重新编译、确认执行路径。
11 / 练习

预测结果,再动手核对

练习 01 / 阈值

把阈值改成 20.0

只改 main.c 中传给 sensor_is_high 的阈值。平均值和状态分别是什么?

查看参考答案

平均值仍为 20.75;因为 20.75 ≥ 20.0,状态变为 HIGH。保存并重新编译两个源文件后再运行。

练习 02 / 函数接口

函数声明放在哪里?

主程序需要调用 sensor_mean。它应该包含头文件,还是把 sensor.c 的源代码直接包含进来?为什么?

查看参考答案

包含 sensor.h 获取声明,并在编译/链接命令中加入 sensor.c。这样主程序和实现共享接口,避免把定义重复编进来。

练习 03 / 调试

观察传入的数组

在 sensor_mean 断点处,使用 print count 和 print samples[0]。结果是什么?

查看参考答案

数量为 4,第一个值为 18。断点处显示的行号和 GDB 结果编号可能不同。

练习 04 / 边界

零长度意味着什么?

在当前约定下,count 为 0 时 sensor_mean 返回什么?这种设计有何不足?

查看参考答案

返回 0.0,不会执行读取循环。但调用方无法区分空数据与真实平均值为零;生产接口需要单独表达错误状态。

12 / 完成标准

能组织、构建,也能观察

  1. 从空目录创建三个文件,编译无错误和未解释的警告,输出与课文一致。
  2. 解释头文件声明、源文件定义与链接各自的作用。
  3. 独立在 sensor_mean 设置断点,观察数量 4、首项 18 和返回值 20.75。
  4. 说明调用方为什么必须保证指针与长度有效,以及零长度返回值的局限。
继续学习

你已经走完这组 C 基础课。接下来可以继续深入指针、结构体、位运算和调试,并逐步把这些能力带到 STM32 项目中。

资料

从官方资料继续查