🌱 Tổng quan về Dấu phẩy động - Floating Point
Trong lập trình nói chung và lập trình C nhúng nói riêng, chúng ta đã biết về kiểu dữ liệu số thực (float). Đối với số nguyên thông thường, dễ thấy chúng có thể biểu diễn dưới dạng nhị phân trên bộ nhớ bằng cách chuyển đổi decimal-binary, vậy còn số thực sẽ biểu diễn dưới dạng nhi phân như thế nào? Bài viết dưới đây sẽ cùng các bạn tìm hiểu về điều đó.
Bài viết sẽ giới thiệu về các thuật ngữ chính về biểu diễn dấu phẩy động, dựa trên tiêu chuẩn IEEE 754, giới hạn trong single và double precision formats (Độ chính xác đơn và kép).
👉 Cách biểu diễn dấu phẩy động theo chuẩn IEEE 754
Dễ thấy, giống như số nguyên, các số thực (float) sẽ được biểu diễn dưới dạng binary trên bộ nhớ (tức các bit 0 và 1). Vậy, đơn giản nhất thì một số thực sẽ biểu diễn dưới dạng sau:
ImIm-1…I2I1I0.F1F2…FnFn-1
Trong đó, Im và Fn sẽ là các bit 0 hoặc 1, lần lượt biểu diễn cho phần nguyên (integer) và phần thực (fraction).
Một số hữu hạn có thể được biểu diễn bằng 4 thành phần số nguyên như sau:
- Bit dấu (s - sign), cơ số (b - base), phần giá trị có nghĩa (m - significant) và số mũ (e - exponent).
- Giá trị của số đó sẽ là:
Tùy thuộc vào base và số lượng bit được sử dụng để mã hóa các thành phần khác nhau, tiêu chuẩn IEEE 754 định nghĩa ra 5 formats cơ bản. Trong đó, binary32 và binary64 formats là single precision (Độ chính xác đơn) và double precision (Độ chính xác kép) formats, với base là 2.
Precision | Base | Sign | Exponent | Significant |
---|---|---|---|---|
Single precision | 2 | 1 | 8 | 23+1 |
Double precision | 2 | 1 | 11 | 52+1 |
👉 Biểu diễn Single Precision Format
Như bản trên đề cập thì Single Precision Format sẽ dành 23-bits cho significant (1 represent implied bit), 8-bits cho số mũ exponent, 1-bit dấu (sign).
Ví dụ, số 9/2 = 4.5 có thể được biểu diễn dưới dạng nhị phân, theo Single precision format như sau:
4.5 = 4 + 0.5 = 4 + 1/2
4 = 100
1/2 = 1
Chuyển sang dạng binary : 4.5 = 100.1 = 1.001 * 2^2
Mantissa (phần sau dấu phẩy) = 00100000000000000000000
Exponent (số mũ) = 2, sẽ cần cộng thêm 127
Exponent = 2 + 127 = 129d = 10000001b
Số dương nên signed bit = 0
Theo chuẩn IEEE 754, biểu diễn của số 4.5 dưới dạng nhị phân sẽ là:
0 10000001 00100000000000000000000 = 0x40900000
Dễ dàng kiểm chứng dạng hexa của một số float bằng cách sử dụng Union trong C như sau:
#include <stdio.h>
#include <stdint.h> typedef union { float floatingpoint; uint32_t integer; } NumType; int main() { NumType Test; Test.floatingpoint = 4.5;
printf("Hexa format = 0x%x", Test.integer); } Run This Code
Dễ thấy 2 phần tử floatingpoint và integer của union NumType sẽ dùng chung bộ nhớ là 32-bits, nên khi in ra phần tử integer dưới dạng hexa cũng chính là in ra phần tử floatingpoint dưới dạng hexa.
➥ Các bạn có thể bấm "Run This Code", kết quả hiển thị trên màn hình console như sau.
Hexa format = 0x40900000
👉 Biểu diễn Double Precision Format
Như đã đề cập ở bảng trên, Double Precision Format sẽ dành 52-bits cho significant (1 represent implied bit), 11-bits cho số mũ exponent, 1-bit dấu (sign).
Cách biến đổi sang dạng binary sẽ tương tự như cách biểu diễn Single Precision Format, các bạn cũng có thể làm tương tự như cách dùng union trên để kiểm chứng với 2 kiểu dữ liệu là double và uint64 trong C.
👉 Độ chính xác của dấu phẩy động
Độ chính xác của các kiểu dấu phẩy động bị giới hạn bởi cách chúng được biểu diễn trong bộ nhớ máy tính, dẫn đến các vấn đề về sai số và giới hạn độ chính xác.
Độ chính xác của dấu phẩy động bị ảnh hưởng bởi các yếu tố sau:
Sai số làm tròn (Rounding Error)
- Máy tính không thể biểu diễn chính xác tất cả các số thực (vì bộ nhớ hữu hạn). Một số số thập phân, như 0.1, trở thành phân số vô hạn trong hệ nhị phân, dẫn đến sai số làm tròn.
- Ví dụ:
- #include <stdio.h>
- int main() {
- float a = 0.1;
- float sum = a + a + a + a + a + a + a + a + a + a; // 0.1 * 10
- printf("Sum: %.20f\n", sum); // Không chính xác là 1.0
- return 0;
- }
Run This Code
➥ 0.1 trong hệ nhị phân là một phân số vô hạn (0.0001100110011...), bị cắt ngắn trong float, gây sai số tích lũy khi cộng nhiều lần. Nên kết quả của phép toán trên sẽ không phải là 1.0.
Sai số tích lũy (Accumulation Error)
- Khi thực hiện nhiều phép toán (cộng, trừ, nhân, chia), sai số làm tròn có thể tích lũy, đặc biệt trong các vòng lặp hoặc tính toán phức tạp.
- Ví dụ: Tính tổng 0.1 + 0.1 + ... (1000 lần).
- #include <stdio.h>
- int main() {
- float sum = 0.0f;
- for (int i = 0; i < 1000; i++) {
- sum += 0.1f;
- }
- printf("Sum: %.10f\n", sum); // Kỳ vọng: 100.0
- return 0;
- }
Run This Code
Sai số do mất ý nghĩa (Loss of Significance)
- Xảy ra khi trừ hai số rất gần nhau, dẫn đến mất các chữ số có nghĩa (cancellation error).
- Ví dụ:
- #include <stdio.h>
- int main() {
- float a = 1.23456789f;
- float b = 1.23456788f;
- float diff = a - b;
- printf("Difference: %.10f\n", diff); // Kỳ vọng: 0.00000001
- return 0;
- }
Run This Code
Kết quả kỳ vọng là 0.00000001 nhưng thực tế chạy là 0.00000000 kết quả có thể sẽ khác tùy vào kiến trúc máy và compiler.
Do cách biểu diễn số thực float chỉ có 23-bit Mantissa, nên float chỉ cung cấp khoảng 6-7 chữ số thập phân chính xác. Các số 1.23456789 và 1.23456788 có 9 chữ số thập phân, vượt quá độ chính xác của float. Khi được gán, chúng bị làm tròn thành các giá trị gần giống nhau (khoảng 1.2345679).
Biểu diễn nhị phân: Trong hệ nhị phân, các số thập phân như 1.23456789 và 1.23456788 không thể được biểu diễn chính xác, dẫn đến sai số làm tròn ngay từ khi lưu trữ. Cụ thể:
- 1.23456789f được làm tròn thành một giá trị gần đúng, ví dụ 1.23456788063049316406....
- 1.23456788f cũng được làm tròn thành giá trị gần giống, có thể bằng chính giá trị của 1.23456789f do giới hạn 23 bit phần định trị (mantissa) của float.
Khi thực hiện a - b, các chữ số giống nhau trong 1.23456789 và 1.23456788 (phần 1.2345678) bị triệt tiêu, chỉ còn lại sự khác biệt ở các chữ số cuối.
Tuy nhiên, vì float đã làm tròn cả hai số thành gần giống hoặc bằng nhau, kết quả của a - b có thể rất nhỏ, thậm chí bằng 0 hoặc một giá trị không đủ lớn để được biểu diễn chính xác trong float.
Giới hạn của mũ (Exponent Limitations)
- Nếu số quá lớn hoặc quá nhỏ, nó có thể gây tràn số (overflow, trở thành vô cực) hoặc dưới ngưỡng (underflow, trở thành 0).
- Ví dụ:
- #include <stdio.h>
- int main() {
- float large = 1e38f * 10.0f; // Tràn số
- printf("Large: %f\n", large); // inf
- return 0;
- }
👉 Kết luận
Dễ thấy với chuẩn IEEE 754, floating point được biểu diễn dưới dạng binary theo một các khá dễ hiểu và rải giá trị khá lớn. Vì vậy, hầu hết các ngôn ngữ lập trình đều cung cấp floating point với cách biểu diễn theo chuẩn IEEE 754.
Tuy nhiên, biểu diễn floating point cũng có một số điểm hạn chế như:
- Cách biểu diễn dễ hiểu với người dùng nhưng khá phức tạp với máy tính, đặc biệt khi tính toán, dẫn đến việc tính toán trên số thực float sẽ có tốc độ thường chậm hơn so với số nguyên.
- Lỗi làm tròn số: Biểu diễn floating point có thể dẫn đến sai sót làm tròn (Có thể dễ dàng kiểm chứng bằng các so sánh float a = 1; với số 1.0).
Ở bài viết sau, mình sẽ cùng bạn tìm hiểu về các tính toán số floating point trong các kiến trúc máy tính và vi điều khiển, cũng như cách hardware support việc tính toán số floating point như thế nào!
>>>>>> Follow ngay <<<<<<<
Để nhận được những bài học miễn phí mới nhất nhé 😊
Chúc các bạn học tập tốt 😊