• 自己编写 .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.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,才会参与编译。

    最后蜘蛛说

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

  • 相关阅读:
    单例模式可以被破坏
    ​标签体系、用户画像、用户分群的区别?​
    搜索与图论:匈牙利算法
    Python之元组、字典和集合详解
    【javaweb】学习日记Day8 - Mybatis入门 Mysql 多表查询 事务 索引
    C语言入门,用什么编译器比较好?
    开放式运动耳机排行榜,排行靠前的五款高性能耳机分享
    自己怎么制作小程序?
    【Verilog基础】阻塞赋值与非阻塞赋值
    Vue - 实现微信扫码登录功能(项目植入微信扫码登录功能)超详细完整流程详解及详细代码及注释,附带完整功能源码、常见问题解决方案
  • 原文地址:https://www.cnblogs.com/zw-awa/p/23017592