C语言中的浮点数、定点数及模块化编程
在编程领域,理解浮点数和定点数的概念以及掌握模块化编程的方法是非常重要的。下面我们将详细探讨这些内容。
浮点数的相关知识
在之前讨论的数字中,我们提到了规范化和非规范化的概念。规范化的数字意味着首位总是有一个有效数字,而那些不符合这个规则的数字被认为是次规范化的。例如,像
+1.2345e - 99
有五个有效数字,而
+0.0001e - 99
只有一个有效数字。
在C语言中,
isnormal
宏用于判断一个数字是否是规范化的,如果是则返回
true
;
issubnormal
宏用于判断一个数字是否是次规范化的,如果是则返回
true
。虽然目前很少有实际的程序会使用次规范化的数字,但我们还是需要了解它们的存在。
浮点数的实现方式有多种。以STM芯片为例,其硬件不支持浮点运算,机器也没有足够的能力通过软件实现浮点运算。一般来说,低端芯片通常没有浮点运算单元,浮点运算需要通过软件库来完成,这会带来一定的性能开销,浮点运算通常比整数运算慢约1000倍。而高端芯片则提供了原生的浮点支持,但浮点运算仍然比整数运算慢约10倍。
定点数的替代方案
处理浮点数的一个好方法是尽量避免使用它。例如在处理货币时,如果将货币存储为浮点数,由于舍入误差,最终可能会导致计算结果不准确。而如果将货币存储为整数(以分为单位),则可以避免浮点数带来的问题。
我们可以定义一种简单的定点数,小数点后的位数固定为2位。以下是一些定点数及其整数实现的示例:
| 定点数 | 整数实现 |
| ---- | ---- |
| 12.34 | 1234 |
| 00.01 | 1 |
| 12.00 | 1200 |
定点数的加法和减法可以直接对其整数实现进行操作:
12.34 1234
+22.22 +2222
------ -----
34.56 2346
98.76 9876
-11.11 -1111
------ ------
87.65 8765
定点数的乘法需要先将两个数相乘,然后除以100来调整小数点的位置:
12.00 1200
x 00.50 x 0050
60000 (未调整)
------ ------
x 06.00 0600 (调整后)
定点数的除法则是先对整数实现进行除法运算,然后乘以一个调整因子。
以下是一个演示定点数使用的程序:
/**
* Demonstrate fixed-point numbers.
*/
#include <stdio.h>
/**
* Our fixed-point numbers have the form
* of xxxxx.xx with two digits to the right
* of the decimal place.
*/
typedef long int fixedPoint; // Fixed-point data type
static const int FIXED_FACTOR = 100; // Adjustment factor for fixed point
/**
* Add two fixed-point numbers.
*
* @param f1 First number to add
* @param f2 Second number to add
* @returns f1+f2
*/
static inline fixedPoint fixedAdd(const fixedPoint f1, const fixedPoint f2)
{
return (f1+f2);
}
/**
* Subtract two fixed-point numbers.
*
* @param f1 First number to subtract
* @param f2 Second number to subtract
* @returns f1-f2
*/
static inline fixedPoint fixedSubtract(const fixedPoint f1, const fixedPoint f2)
{
return (f1-f2);
}
/**
* Multiply two fixed-point numbers.
*
* @param f1 First number to multiply
* @param f2 Second number to multiply
* @returns f1*f2
*/
static inline fixedPoint fixedMultiply(const fixedPoint f1, const fixedPoint f2)
{
return ((f1*f2)/FIXED_FACTOR);
}
/**
* Divide two fixed-point numbers.
*
* @param f1 First number to divide
* @param f2 Second number to divide
* @returns f1/f2
*/
static inline fixedPoint fixedDivide(const fixedPoint f1, const fixedPoint f2)
{
return ((f1*FIXED_FACTOR) / f2);
}
/**
* Turn a fixed-point number into a floating one (for printing).
*
* @param f1 Fixed-point number
* @returns Floating-point number
*/
static inline double fixedToFloat(const fixedPoint f1)
{
return (((double)f1) / ((double)FIXED_FACTOR));
}
/**
* Turn a floating-point number into a fixed one.
*
* @param f1 Floating-point number
* @returns Fixed-point number
*/
static inline fixedPoint floatToFixed(const double f1)
{
return (f1 * ((double)FIXED_FACTOR));
}
int main()
{
fixedPoint f1 = floatToFixed(1.2); // A fixed-point number
fixedPoint f2 = floatToFixed(3.4); // Another fixed-point number
printf("f1 = %.2f\n", fixedToFloat(f1));
printf("f2 = %.2f\n", fixedToFloat(f2));
printf("f1+f2 = %.2f\n", fixedToFloat(fixedAdd(f1, f2)));
printf("f2-f1 = %.2f\n", fixedToFloat(fixedSubtract(f2, f1)));
printf("f1*f2 = %.2f\n", fixedToFloat(fixedMultiply(f1, f2)));
printf("f2/f1 = %.2f\n", fixedToFloat(fixedDivide(f1, f2)));
return (0);
}
需要注意的是,这个实现并不是完美的,在乘法和除法运算中可能会出现舍入误差,但对于熟悉定点数的人来说,这些误差应该很容易发现。
模块化编程的引入
在实际编程中,我们通常会遇到比简单的单文件程序更复杂的情况。例如,Linux内核有33000个文件和2800万行代码,为了更好地管理这些代码,我们需要将其组织成模块。
一个理想的模块是一个包含数据和函数的单一文件,它能够很好地完成一项任务,并且与其他模块的交互最少。下面我们通过一个简单的示例来介绍模块化编程。
我们创建一个使用两个文件的程序,主程序
main.c
将调用
func.c
文件中的函数。同时,我们使用
makefile
来将这两个文件编译成一个程序。
main.c
文件内容如下:
/**
* Demonstrate the use of extern.
* @note: Oversimplifies things.
*/
#include <stdio.h>
extern void funct(void); // An external function
int main()
{
printf("In main()\n");
funct();
return (0);
}
func.c
文件内容如下:
/**
* Demonstration of a function module
*/
#include <stdio.h>
/**
* Demonstration function
*/
void funct(void)
{
printf("In funct()\n");
}
makefile
内容如下:
main: main.c func.c
gcc -g -Wall -Wextra -o main main.c func.c
这个示例展示了模块化编程的基本原理,但它也存在一些问题。例如,相同的信息在
main.c
和
func.c
中重复出现,如果修改一个文件,就必须同时修改另一个文件。而且,C语言不会跨文件检查类型,这可能会导致一些难以调试的问题。
为了解决这些问题,我们可以创建一个头文件
func.h
来存放外部函数的声明:
#ifndef __FUNC_H__
#define __FUNC_H__
extern void funct(void);
#endif // __FUNC_H__
同时,我们对
main.c
和
func.c
进行改进,让它们包含
func.h
头文件:
改进后的
main.c
:
/**
* Demonstrate the use of extern.
*/
#include <stdio.h>
#include "func.h"
int main()
{
printf("In main()\n");
funct();
return (0);
}
改进后的
func.c
:
/**
* Demonstration of a function module
*/
#include <stdio.h>
#include "func.h"
/**
* Demonstration function
*/
void funct(void)
{
printf("In funct()\n");
}
改进后的
makefile
:
CFLAGS = -g -Wall -Wextra
OBJS = main.o func.o
main: $(OBJS)
gcc -g -Wall -Wextra -o main $(OBJS)
main.o: main.c func.h
func.o: func.c func.h
通过这种方式,我们解决了信息重复和类型检查的问题。同时,头文件中的
#ifndef/#endif
对可以防止头文件被重复包含。
良好模块的设计原则
为了创建良好的模块,我们可以遵循以下原则:
- 每个模块应该有一个与模块同名的头文件,该文件应包含模块中公共类型、变量和函数的定义。
- 每个模块都应该包含自己的头文件,以便C语言可以检查头文件和实现是否匹配。
- 模块应该包含用于共同目的的代码,并且向外界暴露最少的信息。通过
extern
声明暴露的信息是全局的,有时可能会带来一些问题。
命名空间和库的问题
C语言没有命名空间,这可能会导致符号冲突的问题。例如,如果在不同的模块中定义了同名的公共函数,链接器会报错。为了解决这个问题,大多数程序员会为每个公共函数、类型或变量添加模块前缀。例如,HAL库中的函数都以
HAL_
开头,这样可以方便地判断一个函数是否属于该库。
在处理程序文件时,当文件数量较少时,列出所有文件还比较容易,但当文件数量变得非常大时,这就变得很繁琐。幸运的是,标准C库是一个名为
libc.a
的文件,在程序链接时会自动加载。
我们可以创建自己的库,例如创建一个包含多个模块的库来对不同类型的数字进行平方运算。以下是相关的代码示例:
square_float.c
文件:
#include "square_float.h"
/**
* Square a floating-point number.
*
* @param number Number to square
* @returns The square of the number
*/
float square_float(const float number) {
return (number * number);
}
square_float.h
文件:
#ifndef __SQUARE_FLOAT_H__
#define __SQUARE_FLOAT_H__
extern float square_float(const float number);
#endif // __SQUARE_FLOAT_H__
square_int.c
文件:
#include "square_int.h"
/**
* Square an integer.
*
* @param number Number to square
* @returns The square of the number
*/
int square_int(const int number) {
return (number * number);
}
square_int.h
文件:
#ifndef __SQUARE_INT_H__
#define __SQUARE_INT_H__
extern int square_int(const int number);
#endif // __SQUARE_INT_H__
square_unsigned.c
文件:
#include "square_unsigned.h"
/**
* Square an unsigned integer.
*
* @param number Number to square
* @returns The square of the number
*/
unsigned int square_unsigned(const unsigned int number) {
return (number * number);
}
square_unsigned.h
文件:
#ifndef __SQUARE_UNSIGNED_H__
#define __SQUARE_UNSIGNED_H__
extern unsigned int square_unsigned(const unsigned int number);
#endif // __SQUARE_UNSIGNED_H__
为了方便用户使用这个库,我们创建一个头文件
square.h
来整合各个模块的头文件:
#ifndef __SQUARE_H__
#define __SQUARE_H__
#include "square_float.h"
#include "square_int.h"
#include "square_unsigned.h"
#endif // __SQUARE_H__
以下是一个测试这个库的程序
square.c
:
/**
* Test the square library.
*/
#include <stdio.h>
#include "square.h"
int main()
{
printf("5 squared is %d\n", square_int(5));
printf("5.3 squared is %f\n", square_float(5.3));
return (0);
}
要将这些源文件变成一个实际的库,我们需要将
square_float.o
、
square_int.o
和
square_unsigned.o
文件打包成
libsquare.a
文件。
make
程序可以帮助我们完成这个任务,例如:
libsquare.a(square_int.o): square_int.o
ar crU libsquare.a square_int.o
这个规则告诉
make
将
square_int.o
添加到
libsquare.a
库中。通过这种方式,我们可以创建和管理自己的库。
综上所述,理解浮点数和定点数的实现及限制,掌握模块化编程的方法,对于编写高质量的C语言程序非常重要。我们应该尽量避免不必要的浮点数使用,合理设计模块,以提高程序的可维护性和性能。同时,我们可以参考Wikipedia上关于IEEE浮点标准的文章(https://en.wikipedia.org/wiki/IEEE_754 )获取更多相关信息。
编程问题思考
- 编写一个计算角度正弦值的函数,需要计算多少个因子才能得到准确的结果?
- 使用浮点数尽可能多地计算圆周率的位数,将数据类型改为双精度浮点数和长双精度浮点数后,分别能多得到多少位?
-
编写一个程序,从
x = 1开始,不断将x除以2,直到(1.0 + x = 1.0),除法的次数就是浮点计算中分数部分的位数。
C语言中的浮点数、定点数及模块化编程(续)
浮点数与定点数的深入探讨
前面我们已经了解了浮点数和定点数的基本概念和实现方式。在实际应用中,浮点数虽然能表示很大范围的数值,但由于其内部的二进制表示方式,会存在精度问题。例如,在进行一些需要高精度计算的场景,如金融计算、科学计算等,浮点数的舍入误差可能会对结果产生显著影响。
而定点数通过固定小数点的位置,避免了浮点数的一些精度问题,尤其适用于对精度要求较高且数值范围相对固定的场景。不过,定点数也有其局限性,比如表示范围相对较窄,如果超出了其表示范围,就会产生溢出问题。
为了更直观地理解浮点数和定点数的差异,我们可以通过一个简单的流程图来展示它们在不同场景下的选择:
graph TD;
A[应用场景] --> B{精度要求高?};
B -- 是 --> C{数值范围大?};
C -- 是 --> D[浮点数];
C -- 否 --> E[定点数];
B -- 否 --> F{计算速度优先?};
F -- 是 --> D[浮点数];
F -- 否 --> E[定点数];
模块化编程的进阶应用
在前面的简单模块化编程示例中,我们展示了如何将一个程序拆分成多个模块,并使用头文件和
makefile
来管理这些模块。随着程序规模的不断增大,模块化编程的优势会更加明显。
当一个程序包含多个模块时,模块之间的依赖关系会变得更加复杂。为了更好地管理这些依赖关系,我们可以使用一些工具和技术。例如,使用
doxygen
工具可以生成代码的文档,帮助开发者更好地理解模块之间的接口和依赖关系。
另外,在设计模块时,我们还需要考虑模块的可测试性。一个好的模块应该是易于测试的,我们可以为每个模块编写单元测试,确保模块的功能正确性。例如,对于前面创建的
square
库,我们可以使用
Unity
等单元测试框架来编写测试用例:
#include "unity.h"
#include "square.h"
void setUp(void) {
// 初始化代码
}
void tearDown(void) {
// 清理代码
}
void test_square_int(void) {
TEST_ASSERT_EQUAL_INT(25, square_int(5));
}
void test_square_float(void) {
float result = square_float(5.3);
TEST_ASSERT_FLOAT_WITHIN(0.001, 28.09, result);
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_square_int);
RUN_TEST(test_square_float);
return UNITY_END();
}
命名空间和库的进一步优化
在C语言中,由于没有命名空间,我们通过添加模块前缀的方式来避免符号冲突。但随着项目的不断发展,模块数量增多,这种方式可能会导致函数名和变量名变得冗长。为了进一步优化,我们可以采用一些约定俗成的命名规则,例如使用缩写或特定的命名模式。
对于库的管理,除了使用
ar
工具创建静态库,我们还可以创建动态库。动态库在程序运行时才被加载,这样可以减少程序的内存占用,并且方便库的更新。在Linux系统中,动态库的扩展名通常为
.so
,在Windows系统中为
.dll
。
创建动态库的步骤如下:
1. 编译源文件为目标文件,使用
-fPIC
选项生成位置无关代码:
gcc -fPIC -c square_float.c -o square_float.o
gcc -fPIC -c square_int.c -o square_int.o
gcc -fPIC -c square_unsigned.c -o square_unsigned.o
-
使用
gcc的-shared选项将目标文件链接成动态库:
gcc -shared -o libsquare.so square_float.o square_int.o square_unsigned.o
- 在程序中使用动态库时,需要在编译时指定库的搜索路径和库名:
gcc -o square_test square.c -L. -lsquare
这里的
-L.
表示在当前目录下搜索库,
-lsquare
表示链接
libsquare.so
库。
总结与展望
通过对浮点数、定点数和模块化编程的学习,我们了解到在C语言编程中,合理选择数据类型和编程方式对于提高程序的性能和可维护性至关重要。
在未来的编程实践中,我们可以进一步探索更高级的编程技巧和工具,如使用多线程编程提高程序的并发性能,使用内存管理工具优化内存使用等。同时,随着技术的不断发展,新的编程语言和编程范式也在不断涌现,我们可以借鉴其他语言的优点,不断提升自己的编程能力。
希望本文能够帮助读者更好地理解C语言中的浮点数、定点数和模块化编程,在实际编程中能够灵活运用这些知识,编写出高质量的程序。
再次提醒大家,如果对IEEE浮点标准感兴趣,可以参考Wikipedia上的相关文章(https://en.wikipedia.org/wiki/IEEE_754 )获取更多信息。也希望大家能够积极思考前面提出的编程问题,不断提升自己的编程水平。
以下是对前面编程问题的一些简单思路,供大家参考:
-
计算角度正弦值的函数
:可以使用泰勒级数展开来计算正弦值,计算的因子数量越多,结果越准确。一般来说,计算到一定阶数后,结果的精度就可以满足大多数需求。
-
计算圆周率的位数
:可以使用莱布尼茨级数、马青公式等方法来计算圆周率。不同的数据类型(浮点数、双精度浮点数、长双精度浮点数)由于其精度不同,能计算出的圆周率位数也不同。
-
计算浮点计算中分数部分的位数
:通过不断将
x
除以2,直到
(1.0 + x = 1.0)
,记录除法的次数,这个次数就是浮点计算中分数部分的位数。可以使用一个循环来实现这个过程。
大家可以根据这些思路,尝试编写相应的代码,加深对这些概念的理解。
超级会员免费看
32

被折叠的 条评论
为什么被折叠?



