Chương 7. Kiểu dữ liệu, biến và số học an toàn
Mục tiêu
- Dùng đúng kiểu độ rộng cố định (
std::uint8_t,std::int32_t...) thay choint,long. - Hiểu vì sao phép tính trung gian tràn số và cách tránh.
- Xử lý bộ đếm thời gian tràn (sau 49,7 ngày) đúng cách.
- Biết khi nào dùng số nguyên, khi nào số thực, và số điểm cố định là gì.
Câu chuyện: nhiệt độ âm 3 mV
Một lập trình viên chuyển giá trị ADC 12 bit sang milivolt bằng công thức raw * 3300 / 4095. Trên PC thử vài giá trị: đúng. Trên chip 16 bit (hoặc khi lập trình viên ép kiểu về 16 bit giữa chừng), giá trị lớn cho kết quả vô lý, dữ liệu gửi lên đám mây sai suốt hai tuần mới ai phát hiện. Lỗi nằm ở kích thước kiểu và thứ tự thu hẹp, không phải công thức.
Kiến thức
1. int không có kích thước cố định
Chuẩn C++ chỉ đảm bảo int ≥ 16 bit, long ≥ 32 bit. Trên AVR 8 bit int thường là 16 bit; trên ARM Cortex-M và PC là 32 bit. Vì vậy firmware dùng kiểu độ rộng cố định trong <cstdint>:
| Kiểu | Ý nghĩa |
|---|---|
std::uint8_t, std::int8_t |
8 bit không dấu / có dấu |
std::uint16_t, std::int16_t |
16 bit |
std::uint32_t, std::int32_t |
32 bit |
std::uint64_t, std::int64_t |
64 bit (chậm và tốn trên MCU 8/16/32 bit) |
std::size_t |
Kích thước/chỉ số (rộng bằng con trỏ) |
bool |
Đúng/sai (thường 1 byte) |
// Kích thước kiểu phụ thuộc nền tảng; kiểu độ rộng cố định thì không.
#include <cstdint>
#include <cstdio>
#include <limits>
int main() {
std::printf("char=%zu short=%zu int=%zu long=%zu long long=%zu void*=%zu\n",
sizeof(char), sizeof(short), sizeof(int), sizeof(long), sizeof(long long), sizeof(void*));
std::printf("uint8_t=%zu uint16_t=%zu uint32_t=%zu uint64_t=%zu\n",
sizeof(std::uint8_t), sizeof(std::uint16_t), sizeof(std::uint32_t), sizeof(std::uint64_t));
std::printf("uint8_t max=%d, int8_t min=%d\n",
static_cast<int>(std::numeric_limits<std::uint8_t>::max()),
static_cast<int>(std::numeric_limits<std::int8_t>::min()));
return 0;
}
char=1 short=2 int=4 long=8 long long=8 void*=8
uint8_t=1 uint16_t=2 uint32_t=4 uint64_t=8
uint8_t max=255, int8_t min=-128
Kết quả trên máy tác giả (Windows 64 bit): long là 4 byte, còn trên Linux 64 bit long là 8 byte — cùng một mã nguồn, hai kích thước khác nhau. Đó là lý do phải dùng kiểu độ rộng cố định khi định dạng dữ liệu (gói tin, thanh ghi).
Quy tắc chọn kiểu:
- Dữ liệu ra ngoài (gói tin, thanh ghi, Flash): kiểu độ rộng cố định.
- Bộ đếm vòng lặp và biến cục bộ trong hàm: có thể dùng
unsigned/intđể trình biên dịch chọn kích thước nhanh nhất; hoặcstd::uint_fast8_t. - Chỉ số mảng:
std::size_t. - Đừng dùng
charcho số; chỉ dùng cho ký tự văn bản. Dữ liệu nhị phân dùngstd::uint8_t(hoặcstd::byte).
2. Số có dấu và không dấu: hai bộ luật khác nhau
Không dấu (unsigned) |
Có dấu (signed) |
|
|---|---|---|
| Tràn số | Xác định: quay vòng (modulo 2ⁿ) | Hành vi không xác định (trình biên dịch được phép giả định "không bao giờ xảy ra") |
| Dùng cho | Bit, thanh ghi, bộ đếm quay vòng, kích thước | Đại lượng có thể âm (nhiệt độ, sai số) |
Hai bẫy điển hình:
uint8_t a = 200, b = 100; auto c = a + b;—clàint(300), vì trước khi cộng, các kiểu nhỏ hơnintđược thăng cấp (integral promotion) lênint. Gán lại vàouint8_tmới bị cắt còn 44.- So sánh số có dấu với không dấu (
-1 < 1ulà sai):-1bị chuyển sang số không dấu rất lớn. Bật-Wall -Wextrađể được cảnh báo-Wsign-compare.
3. Tràn số trung gian
Xem ví dụ chuyển ADC sang mV:
// Chuyển giá trị ADC 12 bit sang mV: cùng công thức, một bản đúng và một bản tràn số.
#include <cstdint>
#include <cstdio>
constexpr std::uint32_t kVrefMv = 3300;
constexpr std::uint32_t kMaxRaw = 4095; // 2^12 - 1
// Đúng: nhân trong 32 bit, chia rồi mới thu hẹp về 16 bit.
constexpr std::uint16_t adc_to_mv(std::uint16_t raw) {
return static_cast<std::uint16_t>((static_cast<std::uint32_t>(raw) * kVrefMv) / kMaxRaw);
}
// Sai: thu hẹp tích về 16 bit TRƯỚC khi chia -> mất các bit cao.
constexpr std::uint16_t adc_to_mv_bug(std::uint16_t raw) {
std::uint16_t product = static_cast<std::uint16_t>(raw * kVrefMv);
return static_cast<std::uint16_t>(product / kMaxRaw);
}
static_assert(adc_to_mv(4095) == 3300);
static_assert(adc_to_mv(0) == 0);
int main() {
const std::uint16_t samples[] = {0, 1024, 2048, 4095};
std::puts("raw dung(mV) sai(mV)");
for (std::uint16_t raw : samples)
std::printf("%4u %9u %8u\n", static_cast<unsigned>(raw),
static_cast<unsigned>(adc_to_mv(raw)), static_cast<unsigned>(adc_to_mv_bug(raw)));
return 0;
}
raw dung(mV) sai(mV)
0 0 0
1024 825 9
2048 1650 2
4095 3300 3
Cột "sai" thu hẹp tích raw * 3300 về 16 bit trước khi chia; với raw = 4095 tích là 13 513 500, bị cắt modulo 65 536 còn 13 084, chia 4095 ra 3 thay vì 3300. Bản đúng nhân trong 32 bit, chia xong rồi mới ép về 16 bit. Quy tắc: thu hẹp kiểu ở bước cuối cùng, và luôn viết static_cast tường minh để người đọc thấy nơi có thể mất thông tin.
4. Bộ đếm thời gian tràn
Hầu hết firmware có millis() đếm mili-giây bằng số 32 bit không dấu. 2³² ms ≈ 49,7 ngày; sau đó nó quay về 0. Thiết bị chạy liên tục sẽ đụng cột mốc này. Cách so sánh thời gian đúng: dùng khoảng thời gian trôi qua, không dùng thời điểm tuyệt đối.
// Bộ đếm mili-giây 32 bit tràn sau khoảng 49,7 ngày. Phép trừ không dấu vẫn cho khoảng thời gian đúng.
#include <cstdint>
#include <cstdio>
#include <initializer_list>
using Tick = std::uint32_t;
constexpr Tick elapsed(Tick now, Tick start) { return now - start; } // đúng cả khi 'now' đã tràn
constexpr bool timed_out_wrong(Tick now, Tick deadline) { return now >= deadline; } // sai khi tràn
constexpr bool timed_out(Tick now, Tick start, Tick timeout) { return elapsed(now, start) >= timeout; }
int main() {
const Tick start = 0xFFFFFF00u; // sắp tràn
const Tick timeout = 1000; // 1 giây
const Tick deadline = start + timeout; // sau khi tràn còn nhỏ hơn start
for (Tick offset : {Tick{100}, Tick{999}, Tick{1000}}) {
Tick now = start + offset;
std::printf("sau %4u ms: dung=%d sai=%d\n", static_cast<unsigned>(offset),
timed_out(now, start, timeout), timed_out_wrong(now, deadline));
}
return 0;
}
sau 100 ms: dung=0 sai=1
sau 999 ms: dung=0 sai=0
sau 1000 ms: dung=1 sai=1
Ở mốc 100 ms, bản "sai" (now >= deadline) báo hết giờ ngay vì deadline đã quay vòng thành số nhỏ, còn now vẫn ở gần đỉnh. Bản đúng now - start >= timeout luôn đúng miễn khoảng đo nhỏ hơn 2³¹ ms, nhờ phép trừ không dấu quay vòng. Đây là lỗi kinh điển: chạy tốt vài tuần trong phòng lab, rồi thiết bị ngoài hiện trường bỗng "hết giờ" ngẫu nhiên.
5. Số thực: khi nào và giá phải trả
- MCU không có FPU (nhiều Cortex-M0/M3, AVR): mọi phép
floatđược mô phỏng bằng phần mềm, chậm (hàng chục đến hàng trăm chu kỳ) và kéo thư viện lớn vào Flash. - MCU có FPU đơn (Cortex-M4F/M7):
floatnhanh, nhưngdoublevẫn chậm nếu FPU chỉ hỗ trợ độ chính xác đơn. Chú ý hậu tố:1.0flàfloat,1.0làdouble. - Số điểm cố định (fixed-point): lưu số thực dưới dạng số nguyên nhân với một hệ số. Ví dụ nhiệt độ 25,3 °C lưu thành
253(đơn vị 0,1 °C) — dùng ở ví dụReadingchương 9. Nhanh, xác định, không cần FPU. - Không so sánh số thực bằng
==; dùng khoảng sai số.
Cách đặt tên giúp tránh nhầm đơn vị: temp_c10 (độ C x10), voltage_mv, timeout_ms. Đây là thói quen rẻ nhất có lợi nhất.
Ví dụ thực tiễn: bộ lọc trung bình cho cảm biến (số nguyên)
Cảm biến nhiệt độ nhiễu ±2 đơn vị; ta lọc bằng trung bình trượt 8 mẫu bằng số nguyên, không dùng float. Trung bình 8 phần tử chia cho 8 = dịch phải 3 bit. Tổng 8 mẫu 12 bit tối đa 8 × 4095 = 32 760 nằm vừa trong uint16_t, nhưng để an toàn ta dùng uint32_t cho tổng — luôn tính độ rộng cần thiết trước khi chọn kiểu. Bạn sẽ hoàn thiện bộ lọc này ở bài tập.
Góc nhúng
- Mỗi lần dùng
std::uint64_thoặcdoubletrên MCU 8/32 bit không FPU, hãy hỏi: có cần thật không? Kiểm tra bằngsize/Compiler Explorer. consttoàn cục vàconstexprnằm trong Flash; tránh biến toàn cục có thể sửa nếu không cần (tiết kiệm RAM và tránh lỗi đồng thời).- Trên AVR,
floatvà chuỗi literal mặc định nằm trong RAM; cần thuộc tínhPROGMEMđể đặt trong Flash — kiến trúc Harvard có quy tắc riêng.
Lỗi thường gặp
- Dùng
intcho dữ liệu gói tin; đổi sang chip khác là hỏng. - Ép kiểu bằng kiểu C
(uint8_t)x— ẩn mất mát thông tin. Dùngstatic_cast. - So sánh thời gian bằng
now >= deadline(mục 4). - Quên hậu tố
ucho hằng lớn:1 << 31là số có dấu tràn; dùng1u << 31. - Chia số nguyên cho 0: kết quả không xác định trên nhiều nền tảng; luôn kiểm tra mẫu số nếu nó đến từ bên ngoài.
- Dịch bit ≥ độ rộng kiểu (
1u << 32): hành vi không xác định.
Bài tập
- Trong
adc_convert.cpp, thêm hàm chuyển ADC sang nhiệt độ (đơn vị 0,1 °C) theo công thứctemp_c10 = raw * 1000 / 4095 - 400bằng số nguyên. Chọn kiểu trung gian đúng vàstatic_asserthai giá trị biên. - Viết
class MovingAverage8(mảng 8 phần tửuint16_t, vòng tròn, tổnguint32_t) với hàmadd(uint16_t) -> uint16_ttrả trung bình bằng dịch phải 3. - Sửa
tick_wrap.cppđểTicklàstd::uint16_t(tràn sau 65 giây). Thử vớitimeout = 60000và giải thích khi nào khoảng đo còn đúng. - Viết chương trình chứng minh
-1 < 1ulà sai; bật-Wall -Wextravà chép cảnh báo. - Tính số bit tối thiểu để lưu độ ẩm 0,0–100,0 % với độ phân giải 0,1 %. Chọn kiểu nhỏ nhất.
Tóm tắt
- Dùng kiểu độ rộng cố định cho dữ liệu ra ngoài;
size_tcho chỉ số. - Số không dấu tràn theo modulo (xác định); số có dấu tràn là hành vi không xác định.
- Kiểu nhỏ hơn
intđược thăng cấp trước khi tính; thu hẹp ở bước cuối vớistatic_cast. - So sánh thời gian bằng
now - start, không bằng thời điểm tuyệt đối. - Ưu tiên số nguyên/điểm cố định trên MCU không có FPU; đặt tên biến kèm đơn vị.