自己编写 .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,能传指针,尽量不要传值拷贝...当然这里有点涉及零拷贝,那又是另外的话题了。
这样做之后,其他文件不能直接这样写:
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.h、counter.H、Counter.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
/*系统库常用尖括号包含*/
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,才会参与编译。
最后蜘蛛说
文件分开以后,代码终于不用全家人挤一张床了。
原文地址: https://www.cveoy.top/t/topic/qHz5 著作权归作者所有。请勿转载和采集!