自己编写 .c 和 .h:把小车代码分开放

刚开始写 C 语言时,很容易自然而然地把所有代码放在一个文件里。

变量在这里,函数在那里,main 也在这里。代码不长的时候,这样写没有什么问题。

可是项目一旦变大,main.c 很快就会变成一个什么都有的文件:

main.c
    计数器
    车灯
    轮子
    喇叭
    按键
    显示
    通信

你想改一个地方,就要在几百行甚至几千行代码里找。更麻烦的是,某个函数到底给谁用、某个变量能不能直接修改,慢慢就分不清了。

所以,代码需要分开。

这里先不接任何硬件,用一个纯 C 的计数器,看看 .c 和 .h 到底怎么配合。

先写一个计数器

先准备三个文件:

main.c
counter.c
counter.h

counter.h 写计数器对外提供的功能:

#ifndef COUNTER_H
#define COUNTER_H

/*
常用防止重复包含的做法

#ifndef xxx ... #endif -> 如果没定义xxx,就运行从#ifndef到#endif之间的命令
#define xxx -> 定义xxx
合起来就是 -> 第一次遇到这段代码,由于没有定义xxx,于是会运行包含的这些代码
里面代码定义了xxx,于是下次遇到这段代码,则不运行包含的这些代码
*/
void Counter_Reset(void);
void Counter_Add(void);
int Counter_Get(void);

#endif

这里没有写具体算法,只写了三个函数的名字、参数和返回值。

这叫函数声明。

它表达的意思是:

  • 这个模块可以重置计数;
  • 这个模块可以增加一次计数;
  • 这个模块可以返回当前计数。

接着,在 counter.c 里写真正的实现:

#include "counter.h"
/*自己创建的库常用双引号包含*/

static int counter_value;
/*
static 表示:这个变量只在当前作用域内部可见。
这里是 counter.c 的全局区域,于是则是表示在 counter.c 内部可见
*/

void Counter_Reset(void)
{
    counter_value = 0;
}

void Counter_Add(void)
{
    counter_value++;
}

int Counter_Get(void)
{
    return counter_value;
}

计数值放在 counter.c 里,并且加上了 static。

static int counter_value;

这里的函数有着“封装”的思想,把counter的变量本身封装成几个函数调用,后续使用不需要也不建议直接操作变量本身,而是通过函数这几个“接口”调用,这种思想在后面程序复杂起来之后能解决很多不必要的麻烦。

至于为什么要使用 static,主要的原因是作用域分离(这里可以简单地将文件当一个大括号,严格来说,这里应该是改变链接性,看的是别的文件还能不能叫出这个名字),让每一个变量能影响到区域越小代码越好,在我看来是内层{}内变量>外层{}内部变量>本文件的变量,其中又是临时变量>static静态变量>全局变量>到处extern的全局变量,其中能 const 的尽量 const;传参的时候先算一笔账:复制的代价比传指针更大,就传指针。int、uint8_t 这种小标量复制进寄存器几乎不花代价,传指针反而更贵;结构体一大,复制那一笔就压过一切,这时候传指针,只读就加 const。C 里没有引用,想把改动带回去也只有传指针这一条路。

这一层省掉的是“实参被复制一份”,也就是这个对象零次拷贝;至于性能优化,那又是另一个话题了。

这样做之后,其他文件不能直接这样写:

counter_value = 100;

它们只能通过 counter.h 中公开的函数来使用计数器:

Counter_Reset();
Counter_Add();
Counter_Get();

最后,在 main.c 中使用这个模块:

#include "counter.h"

int main(void)
{
    int total;

    Counter_Reset();

    Counter_Add();
    Counter_Add();

    total = Counter_Get();

    (void)total; /*防"未使用变量"警告的惯用法*/

    while (1)
    {
    }
}

main.c 不需要知道计数器内部用什么变量,也不需要知道计数是怎么加出来的。

它只需要知道:

  • 我可以重置它;
  • 我可以让它加一;
  • 我可以读取结果。

这就是 .h 和 .c 的分工。

.h 是对外的说明,.c 是内部的实现

可以把一个模块想成一个小房间。

.h 像是贴在门口的说明:

  • 这里提供 Counter_Reset();
  • 这里提供 Counter_Add();
  • 这里提供 Counter_Get()。

.c 是房间里面真正工作的东西:

  • 计数值放在哪里;
  • 计数值如何增加;
  • 计数值如何返回。

因此,.h 里通常放:

  • 函数声明;
  • 对外需要使用的类型;
  • 对外需要使用的宏;
  • 必要的常量。

.c 里通常放:

  • 函数实现;
  • 模块内部变量;
  • 只供本文件使用的辅助函数;
  • 不希望其他模块直接修改的细节。

如果一个变量只在当前文件里使用,通常不需要放进 .h。

为什么不能直接包含 .c

有人会想:

#include "counter.c"

这样不就能直接拿到里面的函数了吗?

不要这样做。

include 的作用,是把一个文件的内容放到当前位置。假如多个 .c 文件都包含同一个 counter.c,编译和链接时就可能出现重复定义。

正确的关系是:

main.c
    └── #include "counter.h"

counter.c
    └── #include "counter.h"

main.c 通过头文件知道函数怎么调用,counter.c 通过头文件检查自己的实现是否和声明一致。

真正参与编译的是 counter.c,不是被 main.c 直接塞进去。

为什么要把模块分开

分开不是为了让工程看起来像“大项目”,而是因为每个文件可以有自己的边界。

修改时不容易互相碰撞

计数器内部把 int 改成 long,只要对外函数不变,main.c 通常不需要跟着修改。

以后轮子模块调整内部变量,车灯模块也不应该受到影响。

每个模块只管自己的事情

轮子模块只管:

  • 初始化轮子;
  • 设置速度;
  • 停止轮子。

车灯模块只管:

  • 初始化车灯;
  • 打开车灯;
  • 关闭车灯;
  • 翻转灯状态。

喇叭模块和按键模块也有自己的职责。

main.c 负责安排顺序:

  • 初始化各个模块;
  • 读取按键;
  • 决定小车动作。

它不应该亲自修改每个模块的内部变量。

更容易复用

这个计数器不依赖任何外设。

以后别的项目也需要计数时,只要把:

counter.c
counter.h

加入新工程,就可以继续使用。

如果把计数代码直接写在 main.c 里,换一个项目就要重新复制、重新整理,还容易把旧项目里的变量一起带过去。

更适合编译器的增量编译机制

编译器通常会分别编译每个 .c 文件,最后再把它们链接起来:

main.c       ─┐
counter.c     ├── 编译 ── 链接 ── 最终程序
其他模块.c   ─┘

如果只修改 counter.c,编译器一般只需要重新编译受影响的文件。

但是,如果所有模块都挤在一个公共文件里,或者一个公共头文件塞进了整个系统的所有内容,那么只改一个小地方,也可能让很多文件重新编译。

项目小时,这只是多等几秒。

项目大起来以后,等待、排错和确认影响范围都会变得麻烦。

在 Keil 里怎么添加自己的 .c 和 .h

文件在电脑文件夹里,并不代表它已经属于 Keil 工程。

这是两个不同的概念:

文件夹里有文件
    ≠
Keil 工程会编译这个文件

以 counter.c 和 counter.h 为例。

新建 counter.c

打开 Keil 工程,在左侧工程窗口中找到准备放置代码的分组,一般可以放在 User 分组。

右键这个分组,选择:

Add New Item to Group

然后选择 C 文件,填写:

counter.c

确认后,Keil 会创建文件,并把它加入当前分组。

此时 counter.c 不只是出现在电脑文件夹里,也已经出现在 Keil 工程的编译列表中。

新建 counter.h

同样右键工程分组,选择 Add New Item to Group,新建头文件:

counter.h

头文件可以加入工程分组,方便查看。

不过,头文件本身一般不会像 .c 文件一样单独编译。它是在其他 .c 文件执行下面这句时被使用的:

#include "counter.h"

添加已经存在的文件

如果 counter.c 已经在电脑上写好了,也可以使用:

Add Existing Files to Group

选择已有的 counter.c,把它加入工程。

如果只把文件复制到工程目录,却没有使用这个菜单加入工程,Keil 可能根本不会编译它。

头文件放在哪里

.h.H 有什么区别

在 Windows 里,.h.H 经常都能作为头文件打开;但在不少编译环境、脚本和版本管理工具中,文件名大小写是有区别的。#include "counter.h"#include "counter.H" 不一定会被当成同一个文件,换到 Linux 或其他构建环境后尤其容易出现“文件明明存在却找不到”的问题。

因此,头文件统一使用小写 .h 扩展名,源文件统一使用小写 .c 扩展名。文件名、宏名和 #include 中的写法也保持完全一致,例如:

#include "counter.h"

不要在同一个工程里混用 counter.hcounter.HCounter.h 这类写法。统一的大小写看起来只是风格问题,实际上能避免跨平台编译和团队协作时的路径错误。

最简单的情况是:

main.c
counter.c
counter.h

三个文件放在同一个目录中。这样写:

#include "counter.h"

编译器通常就能找到。

如果头文件放到了单独的目录,例如:

User
    main.c
    counter.c

Include
    counter.h

那就需要在工程选项中,把 Include 目录加入头文件搜索路径。

在 Keil 中打开工程选项,进入 C/C++ 设置,找到 Include Paths,把头文件所在目录添加进去。

之后,源文件仍然可以这样写:

#include "counter.h"

不需要把完整的电脑路径写进代码。

不要写成:

#include "F:\\某个目录\\counter.h"

换一台电脑、换一个用户目录,路径就失效了。

编译时会发生什么

点击编译或按下 F7 后,编译器大致会做三件事。

第一步,分别编译每个 .c 文件:

main.c    -> main.o
counter.c -> counter.o

第二步,检查每个源文件中使用的函数和变量。

第三步,把这些目标文件链接成最终程序。

如果 main.c 调用了:

Counter_Add();

但 counter.c 没有加入 Keil 工程,最后链接时就可能出现类似“找不到函数实现”的错误。

如果 main.c 没有包含:

#include "counter.h"

编译器可能不知道 Counter_Add() 的声明。

如果 .h 中的声明和 .c 中的实现不一致,也会出现编译或链接问题。

比如头文件写成:

int Counter_Get(void);

源文件却写成:

void Counter_Get(void)
{
}

这就是接口对不上。

所以,.h 和 .c 不是两个互不相干的文件,它们是一份对外约定和一份具体实现。

以后怎么映射到小车

计数器只是一个不依赖硬件的练习。

真正写小车时,可以沿用同样的结构:

main.c

led.h
led.c

motor.h
motor.c

buzzer.h
buzzer.c

key.h
key.c

led.h 只说明车灯能做什么:

#ifndef LED_H
#define LED_H

void LED_Init(void);
void LED_On(void);
void LED_Off(void);
void LED_Toggle(void);

#endif

led.c 负责车灯具体怎么初始化、怎么输出电平。

motor.h 只说明轮子模块对外提供什么操作:

#ifndef MOTOR_H
#define MOTOR_H

#include <stdint.h>
/*系统库常用尖括号包含*/

void Motor_Init(void);
void Motor_SetPercent(int16_t percent);
void Motor_Stop(void);

#endif

motor.c 负责速度、方向以及底层控制细节。

buzzer.h 和 key.h 也是这个形状。它们目前各自只对外提供一个操作:

#ifndef BUZZER_H
#define BUZZER_H

void Buzzer_Init(void);

#endif
#ifndef KEY_H
#define KEY_H

void Key_Init(void);

#endif

main.c 最后只保留这些关系:

#include "led.h"
#include "motor.h"
#include "buzzer.h"
#include "key.h"

int main(void)
{
    LED_Init();
    Motor_Init();
    Buzzer_Init();
    Key_Init();

    while (1)
    {
        /*组织小车的整体动作*/
    }
}

这样写以后,main.c 更像一个指挥者。

它决定先初始化谁、什么时候读取按键、什么时候让轮子转动;至于车灯如何点亮、轮子如何输出、喇叭如何响,则交给各自的模块。

这就是最开始的分层。

它还没有复杂到需要画很大的架构图,但已经有了清楚的方向:

main.c
    ↓
led / motor / buzzer / key
    ↓
各自的具体实现

以后代码继续增加时,可以再往下分。

但每增加一层,都应该有理由。不要为了让文件数量变多,就把一小段代码拆成十几个文件。

最后橘猫说

.h 负责告诉别人“这个模块能做什么”,.c 负责实现“这个模块具体怎么做”。把代码分开放,真正的价值是建立边界:模块各自负责自己的事情,main.c 负责组织整体流程。用 Keil 时要记住,电脑文件夹里的 .c 不等于工程已经加入;只有加入工程分组的 .c,才会参与编译。

最后蜘蛛说

文件分开以后,代码终于不用全家人挤一张床了。

posted @ 2026-09-18 08:31  Zw-awa  阅读(89)  评论(0)    收藏  举报