从C++结构体、类到 PID 控制器:运动控制初学者如何理解 C++ 工程代码

目录

前言

[一、为什么初学 C++ 工程时容易混乱](#一、为什么初学 C++ 工程时容易混乱)

[1.1 能把数据和行为组织清楚](#1.1 能把数据和行为组织清楚)

[1.2 能被多个模块重复使用](#1.2 能被多个模块重复使用)

[1.3 能适应实际工程的开发习惯](#1.3 能适应实际工程的开发习惯)

[二、在工程中,struct 和 class 该如何选](#二、在工程中,struct 和 class 该如何选)

[2.1 struct 更适合"纯数据"](#2.1 struct 更适合“纯数据”)

[2.2 class 更适合"带行为的对象"](#2.2 class 更适合“带行为的对象”)

[2.3 工程中的经验判断标准](#2.3 工程中的经验判断标准)

[(1)适合用 struct 的场景](#(1)适合用 struct 的场景)

[(2)适合用 class 的场景](#(2)适合用 class 的场景)

[三、.h 和 .cpp 分别负责什么](#三、.h 和 .cpp 分别负责什么)

[3.1 .h 是"声明",相当于接口说明书](#3.1 .h 是“声明”,相当于接口说明书)

[3.2 .cpp 是"实现",相当于真正干活的地方](#3.2 .cpp 是“实现”,相当于真正干活的地方)

[3.3 为什么工程里要分开写](#3.3 为什么工程里要分开写)

(1)方便复用

(2)有利于维护

(3)有利于隐藏细节

(4)更符合工程编译方式

[四、.h 和 .cpp 之间到底是什么关系](#四、.h 和 .cpp 之间到底是什么关系)

[4.1 头文件声明"我有什么"](#4.1 头文件声明“我有什么”)

[4.2 源文件实现"我怎么做"](#4.2 源文件实现“我怎么做”)

[4.3 外部文件只需要包含 .h](#4.3 外部文件只需要包含 .h)

[五、什么是类,public 和 private 又是什么意思](#五、什么是类,public 和 private 又是什么意思)

[5.1 类可以理解为一个"控制器模板"](#5.1 类可以理解为一个“控制器模板”)

[5.2 public 表示对外公开的接口](#5.2 public 表示对外公开的接口)

[5.3 private 表示类内部状态](#5.3 private 表示类内部状态)

[六、PID 类中的成员变量和成员函数分别是什么](#六、PID 类中的成员变量和成员函数分别是什么)

[6.1 成员变量:负责存储状态](#6.1 成员变量:负责存储状态)

[(1)PID 参数](#(1)PID 参数)

(2)误差相关状态

(3)限幅参数

[6.2 成员函数:负责执行动作](#6.2 成员函数:负责执行动作)

七、构造函数到底是什么,为什么名字必须和类名一样

[7.1 构造函数的定义](#7.1 构造函数的定义)

(1)函数名必须和类名一样

(2)构造函数没有返回值

[7.2 构造函数的作用](#7.2 构造函数的作用)

[7.3 为什么工程里最好手动写构造函数](#7.3 为什么工程里最好手动写构造函数)

[八、什么是作用域解析符 ::,什么是初始化列表](#八、什么是作用域解析符 ::,什么是初始化列表)

[8.1 :: 是作用域解析符](#8.1 :: 是作用域解析符)

[8.2 : 后面的部分叫初始化列表](#8.2 : 后面的部分叫初始化列表)

[8.3 这一段在 PID 工程中的真实意义](#8.3 这一段在 PID 工程中的真实意义)

[九、#ifndef、#define、#endif 是什么意思](#ifndef、#define、#endif 是什么意思)

[9.1 它的作用是什么](#9.1 它的作用是什么)

[9.2 逐句理解](#9.2 逐句理解)

[(1)#ifndef PID_CONTROLLER_H](#ifndef PID_CONTROLLER_H)

[(2)#define PID_CONTROLLER_H](#define PID_CONTROLLER_H)

(3)#endif

[9.3 为什么必须加头文件保护](#9.3 为什么必须加头文件保护)

[十、适合小车底盘控制C++ 的 PID 类该怎么写](#十、适合小车底盘控制C++ 的 PID 类该怎么写)

[10.1 头文件:PidController.h](#10.1 头文件:PidController.h)

[10.2 源文件:PidController.cpp](#10.2 源文件:PidController.cpp)

[十一、PID 控制器在小车底盘中的典型用法](#十一、PID 控制器在小车底盘中的典型用法)

[11.1 初始化阶段](#11.1 初始化阶段)

[11.2 控制循环阶段](#11.2 控制循环阶段)

十二、总结


前言

在刚接触 C++ 工程开发时,很多人都会被几个问题困住:

  1. 什么情况下该用**struct,什么情况下该用class****?**
  2. **.h.cpp**到底有什么区别?
  3. 类里的**publicprivate、构造函数、作用域解析符 ::**又分别是什么意思?
  4. 如果自己是做运动控制算法的,例如做小车底盘速度控制,那么一个工程里常见的 PID 类该怎么写

这些问题看起来分散,实际上都围绕着一个核心:如何用工程化的方式组织 C++ 代码 。本文就结合运动控制场景,从 structclass、头文件与源文件关系,一直讲到一个适合小车底盘控制的 PID 控制器实现,帮助初学者把这些概念真正串起来。本文内容基于用户整理的学习笔记展开。


一、为什么初学 C++ 工程时容易混乱

对于刚从算法、控制理论或者单片机逻辑过渡到 C++ 工程开发的人来说,常见的困惑并不是公式本身,而是代码结构

一方面,初学者往往已经知道 PID 的比例、积分、微分公式,也能写出简单的控制逻辑;但另一方面,一旦看到工程里的 .h.cppclass、构造函数、成员变量这些写法 ,就会感觉代码突然"复杂了很多"。实际上,这并不是算法变难了,而是因为工程代码不仅要"能算",还要"好维护、好复用、好扩展"。

从工程视角看,一个好的控制模块通常需要做到以下几点:

1.1 能把数据和行为组织清楚

例如,PID 控制器不仅仅包含 kpkikd 这几个参数,还需要保存误差、积分累加值、输出限幅等内部状态。如果这些量全部散落在全局变量里,代码虽然也能运行,但很快就会变得难以维护。

1.2 能被多个模块重复使用

在小车底盘控制中,左轮和右轮通常都需要 PID;如果是四轮车、麦克纳姆轮底盘或舵轮平台,则往往需要更多控制器对象。这就要求 PID模块具有良好的复用性,而不是在每个文件里都重新写一遍。

1.3 能适应实际工程的开发习惯

真正的工程项目里,很少把所有代码都塞进一个文件中。更常见的做法是:

用头文件(.h)声明接口,用源文件(.cpp)实现逻辑;
用类(class)封装控制器状态,用对象实例化具体控制模块;
用公开接口给外部调用,用私有成员保护内部状态。

因此,要真正看懂 PID 工程代码,就必须先把这些基础概念理顺。


二、在工程中,struct 和 class 该如何选

在 C / C++ 项目中,structclass 本质上非常接近。尤其是在 C++ 里,两者最直接的区别其实只有一个:默认访问权限不同struct 默认是 public,而 class 默认是 private

但是在实际工程里,大家通常不会只从语法层面去区分,而是会遵循一套更常见的使用习惯。

2.1 struct 更适合"纯数据"

当一个对象只是用来打包一些变量,而不承担太多逻辑时,通常更适合使用 struct

例如,坐标点、配置项、协议数据包、消息体这类内容,本质上只是"一组数据的集合",并不需要复杂的封装和继承机制。此时用 struct 会显得非常自然。

cpp 复制代码
struct Point
{
    int x;
    int y;
};

这种写法的特点是简单、直接,外部模块可以方便地读取和赋值。

2.2 class 更适合"带行为的对象"

如果一个对象不仅有数据,还需要封装逻辑、控制访问权限、提供成员函数,甚至未来还可能涉及继承和多态,那么更适合使用 class

例如,PID 控制器就是典型的"带行为对象"。它不仅有 kpkikd 等参数,还要具备设置参数、计算输出、重置状态等功能。

cpp 复制代码
class Player
{
private:
int hp;

public:
void attack();
void heal();
};

2.3 工程中的经验判断标准

在实际开发中,可以用一句很实用的话来判断:

只存数据,用 struct;需要封装逻辑和状态管理,用 class。

进一步细化,可以总结为:

(1)适合用 struct 的场景

  • 配置参数
  • 坐标、速度、姿态等简单数据
  • 网络报文、串口协议结构
  • DTO、消息体、传参对象

(2)适合用 class 的场景

  • PID 控制器
  • 电机驱动模块
  • 底盘运动学/逆运动学模块
  • 轨迹跟踪器
  • 状态机、调度器、管理器

对于运动控制算法工程师来说,工程中最常见的习惯就是:数据结构用 struct,控制模块和逻辑模块用 class


三、.h 和 .cpp 分别负责什么

很多初学者第一次看到 C++ 工程代码时,最容易疑惑的就是:为什么一个类要拆成 .h.cpp 两个文件?

实际上,这种拆分正是 C++ 工程化开发的基本习惯。

3.1 .h 是"声明",相当于接口说明书

头文件**.h 的主要作用是告诉编译器:这里有哪些类、函数、结构体、变量可以用**。

也就是说,头文件通常负责写:

  • 类的定义
  • 成员函数声明
  • 结构体定义
  • 宏定义
  • 外部变量声明

例如:

cpp 复制代码
class PidController
{
public:
void setParam(double kp, double ki, double kd);
double calculate(double target, double feedback, double dt);
};

这里并没有写出函数内部具体怎么运行,而只是告诉外部:"这个类里有这些函数,你可以调用它们"。

3.2 .cpp 是"实现",相当于真正干活的地方

源文件**.cpp 负责写函数内部的具体逻辑,即程序到底如何执行**。

例如:

cpp 复制代码
double PidController::calculate(double target, double feedback, double dt)
{
double err = target - feedback;
return err;
}

这里才是函数真正执行计算的地方。

3.3 为什么工程里要分开写

之所以要拆分,主要有以下几个原因。

(1)方便复用

其他文件只需要**#include "PidController.h",就知道如何调用这个类**,而不需要关心实现细节。

(2)有利于维护

头文件主要描述接口,源文件主要描述实现。接口稳定后,实现可以单独修改,不影响外部调用方式。

(3)有利于隐藏细节

使用者只需要知道"怎么用",不一定需要看到"里面怎么写"。这种方式有助于模块封装

(4)更符合工程编译方式

C++ 项目通常是多个 .cpp 文件分别编译,再统一链接成最终程序。如果不区分声明与实现,很容易产生重复定义等问题


四、.h 和 .cpp 之间到底是什么关系

头文件和源文件不是彼此独立的两份代码,而是共同构成一个完整模块的两个部分。

4.1 头文件声明"我有什么"

以 PID 控制器为例,头文件中会写出类名、接口函数和成员变量:

cpp 复制代码
class PidController
{
public:
PidController();
void setParam(double kp, double ki, double kd);
double calculate(double target, double feedback, double dt);

private:
double kp_;
double ki_;
double kd_;
};

4.2 源文件实现"我怎么做"

随后,在 .cpp 中写出这些函数的具体实现:

cpp 复制代码
#include "PidController.h"

PidController::PidController()
{
kp_ = 0;
ki_ = 0;
kd_ = 0;
}

void PidController::setParam(double kp, double ki, double kd)
{
kp_ = kp;
ki_ = ki;
kd_ = kd;
}

4.3 外部文件只需要包含 .h

例如在 main.cppmotor.cppcontrol.cpp 中,只需要包含头文件即可使用该模块:

cpp 复制代码
#include "PidController.h"

PidController pid;

从这个角度看,.h.cpp 的关系可以概括为:

头文件对外公开接口,源文件对内实现逻辑。


五、什么是类,public 和 private 又是什么意思

当我们写下如下代码时:

cpp 复制代码
class PidController
{
public:
PidController();
void setParam(double kp, double ki, double kd);
void setOutputLimit(double min, double max);
void setIntegralLimit(double max);
double calculate(double target, double feedback, double dt);
void reset();

private:
double kp_;
double ki_;
double kd_;
double err_;
double err_last_;
double integral_;
double out_min_;
double out_max_;
double integral_max_;
};

这其实就是在定义一个名为 PidController 的类

5.1 类可以理解为一个"控制器模板"

你可以把类理解成一个模板,或者一个用于创建对象的模型

当我们后面写:

cpp 复制代码
PidController pid;

就是根据这个模板创建了一个具体对象 pid

5.2 public 表示对外公开的接口

public 下面写的函数,外部都可以直接调用。例如:

cpp 复制代码
pid.setParam(10, 1, 0.1);
pid.calculate(target, feedback, dt);
pid.reset();

5.3 private 表示类内部状态

private 下面的成员变量,外部通常不能直接访问,只能通过类提供的接口来间接使用

例如,PID 的误差、积分项、参数限幅等,都属于控制器内部状态。如果这些数据随便暴露给外部读写,就很容易造成逻辑混乱。因此更合理的方式是把它们放在 private 中进行封装。


六、PID 类中的成员变量和成员函数分别是什么

在一个类中,通常既有"数据",也有"动作"。

6.1 成员变量:负责存储状态

在 PID 控制器中,常见的成员变量包括:

cpp 复制代码
double kp_;
double ki_;
double kd_;

double err_;
double err_last_;
double integral_;

double out_min_;
double out_max_;
double integral_max_;

这些量分别表示:

(1)PID 参数

  • kp_:比例系数
  • ki_:积分系数
  • kd_:微分系数

(2)误差相关状态

  • err_:当前误差
  • err_last_:上一时刻误差
  • integral_:误差积分累加值

(3)限幅参数

  • out_min_out_max_:输出限幅
  • integral_max_:积分限幅

这些变量都属于 PID 类的内部状态,所以被称为成员变量。

6.2 成员函数:负责执行动作

PID 类中的成员函数通常包括:

  • PidController():构造函数
  • setParam():设置 PID 参数
  • setOutputLimit():设置输出限幅
  • setIntegralLimit():设置积分限幅
  • calculate():计算一次控制输出
  • reset():清零历史状态

可以简单理解为:

不带括号、用于存数据的,是成员变量;

带括号、用于做动作的,是成员函数。


七、构造函数到底是什么,为什么名字必须和类名一样

构造函数是初学 C++ 时必须掌握的概念之一。

7.1 构造函数的定义

构造函数有两个非常重要的特点:

(1)函数名必须和类名一样

例如类名叫 PidController,那么构造函数就必须叫:

cpp 复制代码
class PidController
{
public:
PidController();

(2)构造函数没有返回值

前面不能写 void,也不能写 intdouble。只要写了返回值类型,它就不再是构造函数了。

7.2 构造函数的作用

构造函数会在对象创建时自动调用,用于初始化对象内部状态。

例如:

cpp 复制代码
PidController pid;

这一行执行时,系统就会自动调用**PidController()**。

7.3 为什么工程里最好手动写构造函数

虽然 C++ 在某些情况下会自动生成默认构造函数,但在工程开发中,尤其是运动控制这种对初始化非常敏感的场景里,通常都建议手动写构造函数。

原因很简单:

如果误差、积分项、限幅等变量没有正确初始化,那么控制器一开始就可能带着"随机值"工作,从而导致输出异常。


八、什么是作用域解析符 ::,什么是初始化列表

很多人第一次看到下面这段代码时会觉得陌生:

cpp 复制代码
PidController::PidController()
: kp_(0), ki_(0), kd_(0)
, err_(0), err_last_(0)
, integral_(0)
, out_min_(-100), out_max_(100)
, integral_max_(100)
{
}

实际上,这段代码只是在实现构造函数并初始化成员变量

8.1 :: 是作用域解析符

A::B 可以理解成:B 是 A 这个范围里的东西。

在类的实现中:

cpp 复制代码
PidController::calculate(...)

表示:这个 calculate 函数是属于 PidController 类的成员函数

除了类成员实现外,:: 还常用于命名空间,例如:

cpp 复制代码
std::cout

表示 cout 属于 std 命名空间。

8.2 : 后面的部分叫初始化列表

构造函数中的这一段:

cpp 复制代码
: kp_(0), ki_(0), kd_(0)

表示在函数体 {} 执行之前,先把这些成员变量初始化为对应值

它和下面这种写法在效果上类似:

cpp 复制代码
PidController::PidController()
{
kp_ = 0;
ki_ = 0;
kd_ = 0;
}

不过初始化列表通常更规范,也更符合工程写法,尤其在成员较多或涉及常量、引用成员时更有优势。

8.3 这一段在 PID 工程中的真实意义

对于 PID 控制器来说,初始化列表的意义非常明确:

  • kp_ki_kd_ 初始化为默认值
  • 将误差和积分状态清零
  • 给输出与积分设置默认限幅
  • 保证控制器对象一创建就是"干净状态"

否则控制器刚启动时,就可能因为内部状态是未定义值而导致输出异常。


九、#ifndef、#define、#endif 是什么意思

在头文件里,我们经常能看到下面这种写法:

cpp 复制代码
#ifndef PID_CONTROLLER_H
#define PID_CONTROLLER_H

class PidController
{
// ...
};

#endif // PID_CONTROLLER_H

这三句合起来通常被称为头文件保护

9.1 它的作用是什么

作用非常简单:防止头文件被重复包含

9.2 逐句理解

(1)#ifndef PID_CONTROLLER_H

表示:如果 PID_CONTROLLER_H 这个宏还没有被定义过。

(2)#define PID_CONTROLLER_H

那么现在就定义它。

(3)#endif

结束这个条件判断。

9.3 为什么必须加头文件保护

在实际工程中,一个头文件可能会被多个 .cpp 文件包含。如果没有头文件保护,就可能出现类、结构体、函数声明重复定义的问题,从而导致编译报错

所以可以把这三句理解成一句话:

让这个头文件的内容在整个编译过程中只生效一次。


十、适合小车底盘控制C++ 的 PID 类该怎么写

在运动控制工程中,PID 是最常见的控制器之一。

例如小车底盘的左右轮速度闭环、舵机转角控制、位置环外环调节等,都可能用到 PID。

下面给出一套比较适合工程集成的 PID 控制器实现。其特点是:

  • 结构清晰
  • 封装明确
  • 支持输出限幅
  • 支持积分限幅
  • 适合在底盘速度环或位置环中直接复用

10.1 头文件:PidController.h

cpp 复制代码
#ifndef PID_CONTROLLER_H
#define PID_CONTROLLER_H

class PidController
{
public:
PidController();

// 设置 PID 参数
void setParam(double kp, double ki, double kd);

// 设置输出限幅
void setOutputLimit(double min, double max);

// 设置积分限幅
void setIntegralLimit(double max);

// 计算一次 PID 输出
double calculate(double target, double feedback, double dt);

// 重置 PID
void reset();

private:
double kp_;
double ki_;
double kd_;

double err_;
double err_last_;
double integral_;

double out_min_;
double out_max_;
double integral_max_;
};

#endif // PID_CONTROLLER_H

10.2 源文件:PidController.cpp

cpp 复制代码
#include "PidController.h"
#include <cmath>

PidController::PidController()
: kp_(0), ki_(0), kd_(0)
, err_(0), err_last_(0)
, integral_(0)
, out_min_(-100), out_max_(100)
, integral_max_(100)
{
}

void PidController::setParam(double kp, double ki, double kd)
{
kp_ = kp;
ki_ = ki;
kd_ = kd;
}

void PidController::setOutputLimit(double min, double max)
{
out_min_ = min;
out_max_ = max;
}

void PidController::setIntegralLimit(double max)
{
integral_max_ = max;
}

double PidController::calculate(double target, double feedback, double dt)
{
err_ = target - feedback;

// 比例项
double p = kp_ * err_;

// 积分项
integral_ += err_ * dt;
if (std::fabs(integral_) > integral_max_)
{
integral_ = (integral_ > 0) ? integral_max_ : -integral_max_;
}
double i = ki_ * integral_;

// 微分项
double d = kd_ * (err_ - err_last_) / dt;
err_last_ = err_;

// 总输出
double output = p + i + d;

// 输出限幅
if (output > out_max_) output = out_max_;
if (output < out_min_) output = out_min_;

return output;
}

void PidController::reset()
{
err_ = 0;
err_last_ = 0;
integral_ = 0;
}

这套写法与用户给出的控制器结构和解释是一致的,本质上就是用一个类来封装 PID 参数、误差状态以及控制输出逻辑。


十一、PID 控制器在小车底盘中的典型用法

在双轮差速底盘中,最常见的做法是左右轮各使用一个 PID 控制器对象。

11.1 初始化阶段

cpp 复制代码
PidController pid_left;
PidController pid_right;

void init()
{
pid_left.setParam(8.0, 0.8, 0.2);
pid_right.setParam(8.0, 0.8, 0.2);

pid_left.setOutputLimit(-7200, 7200);
pid_right.setOutputLimit(-7200, 7200);

pid_left.setIntegralLimit(5000);
pid_right.setIntegralLimit(5000);
}

11.2 控制循环阶段

cpp 复制代码
void controlLoop()
{
double dt = 0.01;

double target_left = 120;
double target_right = 120;

double fb_left = getMotorSpeedLeft();
double fb_right = getMotorSpeedRight();

double pwm_left = pid_left.calculate(target_left, fb_left, dt);
double pwm_right = pid_right.calculate(target_right, fb_right, dt);

setMotorPWMLeft(pwm_left);
setMotorPWMRight(pwm_right);
}

这类写法的优点在于:

每个轮子的 PID 状态相互独立,不会互相干扰;

而且外部控制逻辑也非常清晰,只需要给目标值、反馈值和采样周期即可。


十二、总结

总的来看,structclass.h.cpp、构造函数、作用域解析符 ::、头文件保护这些内容,表面上像是彼此分散的 C++ 基础语法,实际上它们共同服务于同一个目标,那就是让代码具备更好的工程组织能力。对于刚接触运动控制开发的初学者而言,真正需要建立的并不只是"会写几个函数"的能力,而是如何把参数、状态和控制逻辑按照模块化、可复用、易维护的方式组织起来。

以 PID 控制器为例,如果只是为了临时验证公式,几行代码或许就足够了;但一旦进入实际项目,例如小车底盘速度闭环、电机控制、舵机角度调节等场景,就必须考虑接口声明与实现分离、内部状态封装、对象初始化、输出限幅与积分限幅等工程问题。也正因为如此,使用 class 来封装 PID 控制器,配合 .h.cpp 的模块拆分方式,才会成为 C++ 控制工程中最常见、也最实用的写法。

因此,学习这部分内容的关键,并不在于死记硬背每一个语法点,而在于理解它们在工程中的真实作用。只有当你能够把这些基础概念和具体控制场景联系起来,才能真正从"会写代码"走向"会写工程代码"。对于正在学习机器人、小车底盘或运动控制算法的同学来说,这一步往往比单纯掌握 PID 公式本身更重要。

相关推荐
㓗冽2 小时前
2026.03.27(第三天)
数据结构·c++·算法
SWAGGY..2 小时前
【C++初阶】:(5)内存管理
java·c++·算法
liulilittle3 小时前
SQLite3增删改查(C
c语言·开发语言·数据库·c++·sqlite
CVer儿3 小时前
c++的移动语义
c++
逻辑君4 小时前
Research in Brain-inspired Computing [7]-带关节小人(3个)推箱的类意识报告
c++·人工智能·神经网络·机器学习
txinyu的博客4 小时前
解析muduo源码之 HttpResponse.h & HttpResponse.cc
c++
小白学习记录555555 小时前
vs2019无法自动补全QT代码
c++
小糯米6015 小时前
C++ 单调栈原理与模板
开发语言·c++·算法
XZXZZX5 小时前
ATCODER ABC 450 C题解
c++·算法·ccf csp