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

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

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

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

text 复制代码
main.c
    计数器
    车灯
    轮子
    喇叭
    按键
    显示
    通信

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

所以,代码需要分开。

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

先写一个计数器

先准备三个文件:

text 复制代码
main.c
counter.c
counter.h

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

c 复制代码
#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 里写真正的实现:

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。

c 复制代码
static int counter_value;

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

至于为什么要使用 static,主要的原因是作用域分离,让每一个变量能影响到区域越小代码越好,在我看来是内层{}内变量>外层{}内部变量>本文件的变量,其中又是临时变量>static静态变量>全局变量>到处extern的全局变量,其中能const的尽量const,能传指针,尽量不要传值拷贝...当然这里有点涉及零拷贝,那又是另外的话题了。

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

c 复制代码
counter_value = 100;

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

c 复制代码
Counter_Reset();
Counter_Add();
Counter_Get();

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

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

有人会想:

c 复制代码
#include "counter.c"

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

不要这样做。

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

正确的关系是:

text 复制代码
main.c
    └── #include "counter.h"

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

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

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

为什么要把模块分开

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

修改时不容易互相碰撞

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

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

每个模块只管自己的事情

轮子模块只管:

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

车灯模块只管:

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

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

main.c 负责安排顺序:

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

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

更容易复用

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

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

text 复制代码
counter.c
counter.h

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

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

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

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

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

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

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

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

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

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

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

这是两个不同的概念:

text 复制代码
文件夹里有文件
    ≠
Keil 工程会编译这个文件

以 counter.c 和 counter.h 为例。

新建 counter.c

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

右键这个分组,选择:

text 复制代码
Add New Item to Group

然后选择 C 文件,填写:

text 复制代码
counter.c

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

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

新建 counter.h

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

text 复制代码
counter.h

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

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

c 复制代码
#include "counter.h"

添加已经存在的文件

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

text 复制代码
Add Existing Files to Group

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

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

头文件放在哪里

.h.H 有什么区别

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

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

c 复制代码
#include "counter.h"

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

最简单的情况是:

text 复制代码
main.c
counter.c
counter.h

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

c 复制代码
#include "counter.h"

编译器通常就能找到。

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

text 复制代码
User
    main.c
    counter.c

Include
    counter.h

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

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

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

c 复制代码
#include "counter.h"

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

不要写成:

c 复制代码
#include "F:\\某个目录\\counter.h"

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

编译时会发生什么

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

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

text 复制代码
main.c    -> main.o
counter.c -> counter.o

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

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

如果 main.c 调用了:

c 复制代码
Counter_Add();

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

如果 main.c 没有包含:

c 复制代码
#include "counter.h"

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

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

比如头文件写成:

c 复制代码
int Counter_Get(void);

源文件却写成:

c 复制代码
void Counter_Get(void)
{
}

这就是接口对不上。

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

以后怎么映射到小车

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

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

text 复制代码
main.c

led.h
led.c

motor.h
motor.c

buzzer.h
buzzer.c

key.h
key.c

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

c 复制代码
#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 只说明轮子模块对外提供什么操作:

c 复制代码
#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 也是这个形状。它们目前各自只对外提供一个操作:

c 复制代码
#ifndef BUZZER_H
#define BUZZER_H

void Buzzer_Init(void);

#endif
c 复制代码
#ifndef KEY_H
#define KEY_H

void Key_Init(void);

#endif

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

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 更像一个指挥者。

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

这就是最开始的分层。

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

text 复制代码
main.c
    ↓
led / motor / buzzer / key
    ↓
各自的具体实现

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

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

最后橘猫说

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

最后蜘蛛说

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

相关推荐
周洲083012 小时前
STM32 ADC 采集实战|电压采集、分压计算、数据滤波
stm32·单片机·嵌入式硬件
LCG元16 小时前
STM32F103 复位来源诊断实战:RCC_CSR 标志位判优、BKP 复位计数持久化与六种复位源注入实测
stm32·单片机·嵌入式硬件
zhangzhangkeji19 小时前
stm32 5-2 : 中断,代码篇,对射式红外计数器,旋转编码器。 本 M3中等密度的中断向量列表
stm32
Zw-awa21 小时前
标准外设库到底在替你做什么:从时钟到 GPIO 的一次完整调用
stm32
zhangzhangkeji1 天前
srm32 , 6-2 :定时器中断与定时器外部时钟
stm32
新晨单片机设计1 天前
S018A-基于STM32单片机脉搏计(光电对管)【Proteus仿真+Keil程序+原理图】
stm32·单片机·嵌入式硬件·proteus
youcans_1 天前
【嵌入式软件AI编程】12. Claude Code的基本操作
stm32·单片机·ai编程·嵌入式软件·claude code
Brilliantwxx1 天前
【STM32】 从HAL库源码深度解析I2C(源码解析+面试题)
开发语言·stm32·单片机·嵌入式硬件·架构
老当益壮梁奶奶1 天前
51单片机DS18B20温度传感器详解:单总线时序与C51驱动实现
笔记·stm32·单片机·嵌入式硬件·51单片机