4、实战案例:基于C语言实现一个数学计算库
好了,理论讲了一大堆,是时候动手了。这一章我们直接撸代码,做一个简单的数学计算库。我会分别用静态库和动态库两种方式来实现,让你亲眼看看它们到底有什么区别。
我个人习惯,做这种小项目时,先把目录结构搭好。这样后面写代码、编译、测试,思路都清晰很多。
4.1 项目目录结构
先看看我打算怎么组织文件:
mathlib/
├── include/
│ └── mathlib.h # 头文件
├── src/
│ ├── add.c # 加法
│ ├── sub.c # 减法
│ ├── mul.c # 乘法
│ └── div.c # 除法
├── build.sh # 编译脚本(静态库)
├── build_dynamic.sh # 编译脚本(动态库)
└── test/
└── test_math.c # 测试程序
嗯,这里要注意:头文件和源文件分开存放,这是工程化的基本素养。你想想看,如果所有文件都堆在一个目录里,项目大了以后找东西得累死。
4.2 头文件设计
头文件是库的“门面”。用户能看到的就是它,所以设计要干净、规范。
// include/mathlib.h
#ifndef MATHLIB_H
#define MATHLIB_H
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief 加法运算
* @param a 加数
* @param b 加数
* @return a + b
*/
double add(double a, double b);
/**
* @brief 减法运算
* @param a 被减数
* @param b 减数
* @return a - b
*/
double sub(double a, double b);
/**
* @brief 乘法运算
* @param a 乘数
* @param b 乘数
* @return a * b
*/
double mul(double a, double b);
/**
* @brief 除法运算
* @param a 被除数
* @param b 除数(不能为0)
* @return a / b
*/
double div_op(double a, double b);
#ifdef __cplusplus
}
#endif
#endif /* MATHLIB_H */
这里我加了个 extern "C" 的宏判断。为什么?因为C++编译器会对函数名做名字修饰(name mangling),如果不加这个,C++程序链接C库时会找不到符号。我在项目中遇到过好几次这种坑,所以现在写头文件都会顺手加上。
4.3 源文件编写
每个函数一个文件,简单明了。我选几个给你看看:
// src/add.c
#include "mathlib.h"
double add(double a, double b) {
return a + b;
}
// src/div.c
#include "mathlib.h"
double div_op(double a, double b) {
if (b == 0.0) {
// 实际项目中应该用错误码或异常处理
return 0.0;
}
return a / b;
}
其他几个文件(sub.c、mul.c)结构完全一样,我就不贴了。说白了,数学库的核心就是这些四则运算,但麻雀虽小五脏俱全。
4.4 编译脚本——静态库方式
先来静态库的编译脚本。我习惯用 build.sh 来命名:
#!/bin/bash
# build.sh - 编译静态库
# 1. 编译所有源文件为目标文件
gcc -c src/add.c -I include -o add.o
gcc -c src/sub.c -I include -o sub.o
gcc -c src/mul.c -I include -o mul.o
gcc -c src/div.c -I include -o div.o
# 2. 打包成静态库
ar rcs libmath.a add.o sub.o mul.o div.o
# 3. 编译测试程序并链接静态库
gcc test/test_math.c -I include -L . -lmath -o test_static
# 4. 清理中间文件
rm -f *.o
echo "静态库编译完成!运行: ./test_static"
这里有个细节:-I include 指定头文件路径,-L . 指定库文件路径,-lmath 表示链接 libmath.a。我曾经见过新手把 -lmath 写成 -llibmath,结果链接失败,折腾了半天才发现是命名问题。
4.5 编译脚本——动态库方式
动态库的编译脚本稍有不同:
#!/bin/bash
# build_dynamic.sh - 编译动态库
# 1. 编译位置无关代码的目标文件
gcc -c -fPIC src/add.c -I include -o add.o
gcc -c -fPIC src/sub.c -I include -o sub.o
gcc -c -fPIC src/mul.c -I include -o mul.o
gcc -c -fPIC src/div.c -I include -o div.o
# 2. 生成动态库
gcc -shared -o libmath.so add.o sub.o mul.o div.o
# 3. 编译测试程序并链接动态库
gcc test/test_math.c -I include -L . -lmath -o test_dynamic
# 4. 清理中间文件
rm -f *.o
echo "动态库编译完成!运行前请设置 LD_LIBRARY_PATH"
echo "export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH"
echo "运行: ./test_dynamic"
看到 -fPIC 这个选项了吗?它表示生成位置无关代码(Position Independent Code)。动态库加载时地址不确定,所以必须用PIC。我记得有一次忘了加这个,编译没报错,但运行时直接段错误,排查了好久才发现是这个问题。
4.6 测试程序
测试程序用来验证库的功能:
// test/test_math.c
#include <stdio.h>
#include "mathlib.h"
int main() {
double a = 10.0, b = 3.0;
printf("a = %.2f, b = %.2f\n", a, b);
printf("add: %.2f + %.2f = %.2f\n", a, b, add(a, b));
printf("sub: %.2f - %.2f = %.2f\n", a, b, sub(a, b));
printf("mul: %.2f * %.2f = %.2f\n", a, b, mul(a, b));
printf("div: %.2f / %.2f = %.2f\n", a, b, div_op(a, b));
// 测试除零情况
printf("div by zero: %.2f / 0.0 = %.2f\n", a, div_op(a, 0.0));
return 0;
}
4.7 核心逻辑流程图
下面我用一张SVG图来展示整个库的构建和链接流程:
核心要点:静态库在链接时被完整复制到可执行文件中,动态库则在运行时加载。前者生成的文件大但独立,后者生成的文件小但依赖外部库文件。
4.8 运行测试
编译完成后,运行测试程序看看效果:
# 静态库测试
$ ./test_static
a = 10.00, b = 3.00
add: 10.00 + 3.00 = 13.00
sub: 10.00 - 3.00 = 7.00
mul: 10.00 * 3.00 = 30.00
div: 10.00 / 3.00 = 3.33
div by zero: 10.00 / 0.00 = 0.00
# 动态库测试(需要先设置 LD_LIBRARY_PATH)
$ export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
$ ./test_dynamic
a = 10.00, b = 3.00
add: 10.00 + 3.00 = 13.00
sub: 10.00 - 3.00 = 7.00
mul: 10.00 * 3.00 = 30.00
div: 10.00 / 3.00 = 3.33
div by zero: 10.00 / 0.00 = 0.00
输出结果完全一致。但如果你把 libmath.so 删掉,再运行 test_dynamic,就会报错说找不到共享库。而 test_static 则完全不受影响。这就是两者最直观的区别。
小技巧:用 ldd test_dynamic 命令可以查看可执行文件依赖了哪些动态库。如果某个库显示 "not found",说明运行时加载路径有问题。
注意:动态库的搜索路径优先级是:LD_LIBRARY_PATH 环境变量 > /etc/ld.so.cache 缓存 > /lib、/usr/lib 等默认路径。生产环境中建议将动态库安装到系统标准路径,或者使用 rpath 编译选项硬编码路径。
好了,这个实战案例就到这里。你可以在自己的机器上动手试试,把代码敲一遍,感受一下静态库和动态库在编译、链接、运行时的差异。只有亲手做过,才能真正理解这些概念。