Thực hành hiệu chuẩn load cell bằng quả cân với HX711 và ESP32: khớp đa thức và độ không đảm bảo
Tự hiệu chuẩn load cell 5 kg bằng quả cân với HX711, ESP32: ghi điểm kèm độ lệch chuẩn, khớp đa thức bậc 1, bậc 2, lưu hệ số, kiểm tra lại và ước lượng độ không đảm bảo.
Các bài trước của loạt đã nói về lực kế chuẩn, máy chuẩn lực và các nguồn sai số khi đo lực. Bài này là phần thực hành: tự hiệu chuẩn một load cell thanh 5 kg đọc bằng HX711 và ESP32, dùng bộ quả cân làm chuẩn. Chương trình trong bài ghi từng điểm hiệu chuẩn kèm độ lệch chuẩn, khớp đa thức bậc 1 và bậc 2 bằng bình phương tối thiểu, tự chọn bậc phù hợp, lưu hệ số vào bộ nhớ của ESP32 và đổi khối lượng ra lực theo gia tốc trọng trường tại chỗ. Cuối bài là cách kiểm tra lại và ước lượng độ không đảm bảo của chính bộ cân vừa hiệu chuẩn.
1. Chuẩn bị
- ESP32 DevKit V1 30 chân.
- Module HX711 đọc load cell 24 bit.
- Load cell dạng thanh 5 kg (loại 4 dây) cùng đế gá cứng và khay đặt vật.
- Bộ quả cân có giấy hiệu chuẩn hoặc đã biết cấp chính xác, ví dụ cấp M1: 500 g, 1 kg, 2 kg và thêm đủ để ghép tới 5 kg. Quả cân là chuẩn của phép hiệu chuẩn này, nên độ chính xác của bộ cân không thể tốt hơn quả cân.
- Dây cắm test board, nhiệt kế phòng nếu có.
2. Nối dây và gá load cell

| HX711 | Nối tới |
|---|---|
| E+, E−, A−, A+ | Load cell: đỏ, đen, trắng, xanh lá (đối chiếu nhãn của load cell) |
| VCC | ESP32 3V3 |
| GND | ESP32 GND |
| DT | ESP32 GPIO32 |
| SCK | ESP32 GPIO33 |
Phần gá quan trọng không kém phần điện: bắt load cell chắc vào đế nặng và cứng bằng đúng hai lỗ ren ở một đầu, khay đặt vật bắt vào hai lỗ ở đầu kia, đúng chiều mũi tên in trên thân. Khay không được chạm vào đế hay vật khác. Đây là nguồn sai số lớn nhất của cả bộ cân, như ngân sách ở mục 7 cho thấy.
3. Chương trình
Chương trình đọc HX711 trực tiếp bằng xung SCK, không cần thư viện ngoài; thư viện Preferences để lưu hệ số có sẵn trong gói esp32. Các lệnh gõ trong Serial Monitor (115200 baud, chọn kiểu xuống dòng Newline):
| Lệnh | Việc làm |
|---|---|
| t | Trừ bì: đọc 32 mẫu khi khay trống, lấy trung bình làm điểm không |
| d500 | Ghi một điểm hiệu chuẩn với quả cân 500 g đang đặt trên khay |
| l | Liệt kê các điểm đã ghi |
| x | Xóa các điểm để làm lại |
| k | Khớp bậc 1 và bậc 2, in phần dư, chọn bậc và lưu hệ số |
| m | Bật hoặc tắt chế độ đo liên tục |
| g9.7869 | Đặt gia tốc trọng trường tại chỗ để đổi khối lượng ra lực |
/*
PYWORLD.VN - Thực hành hiệu chuẩn load cell bằng quả cân: HX711, ESP32, khớp đa thức bình phương tối thiểu
HX711: VCC = 3V3, GND = GND, DT = GPIO32, SCK = GPIO33; load cell: E+ đỏ, E- đen, A- trắng, A+ xanh lá
Lệnh Serial: t trừ bì, d<gam> ghi điểm, l liệt kê, x xóa điểm, k khớp và lưu, m bật/tắt đo, g<số> đặt g
*/
#include <Preferences.h>
#define CHAN_DT 32
#define CHAN_SCK 33
#define SO_DIEM_TOI_DA 12
#define SO_MAU_DIEM 32 // Số lần đọc cho mỗi điểm hiệu chuẩn và khi trừ bì (khoảng 3 giây ở 10 mẫu/giây)
#define TI_LE_X 100000.0 // Khớp theo u = số đếm / 100000 để hệ phương trình không bị lệch thang
Preferences boNho;
portMUX_TYPE khoaNgat = portMUX_INITIALIZER_UNLOCKED;
int32_t diemKhong = 0; // Số đếm khi không tải (bì)
float khoiLuongDiem[SO_DIEM_TOI_DA]; // Khối lượng quả cân của từng điểm, gam
double demDiem[SO_DIEM_TOI_DA]; // Số đếm trung bình đã trừ bì
float lechChuanDiem[SO_DIEM_TOI_DA]; // Độ lệch chuẩn của các lần đọc, số đếm
int soDiem = 0;
double heSo[3] = {0.0, TI_LE_X / 210.0, 0.0}; // gam = c0 + c1*u + c2*u*u; giá trị đầu gần đúng cho load cell 5 kg
int bacDaThuc = 1;
float giaTocDiaPhuong = 9.7821; // m/s², ví dụ TP.HCM; đặt lại bằng lệnh g theo nơi dùng
bool dangDo = true;
// Đọc một mẫu 24 bit của kênh A, hệ số khuếch đại 128
bool docTho(int32_t *ketQua) {
uint32_t batDau = millis();
while (digitalRead(CHAN_DT) == HIGH) { // DT xuống thấp khi HX711 có dữ liệu mới
if (millis() - batDau > 500) {
return false;
}
delay(1);
}
uint32_t giaTri = 0;
// SCK ở mức cao quá 60 µs thì HX711 tự tắt, nên không cho ngắt chen vào giữa chuỗi xung
portENTER_CRITICAL(&khoaNgat);
for (int i = 0; i < 24; i++) {
digitalWrite(CHAN_SCK, HIGH);
delayMicroseconds(1);
giaTri = (giaTri << 1) | (uint32_t)digitalRead(CHAN_DT);
digitalWrite(CHAN_SCK, LOW);
delayMicroseconds(1);
}
digitalWrite(CHAN_SCK, HIGH); // Xung thứ 25: kênh A, hệ số 128 cho lần đo sau
delayMicroseconds(1);
digitalWrite(CHAN_SCK, LOW);
portEXIT_CRITICAL(&khoaNgat);
if (giaTri & 0x800000) {
giaTri |= 0xFF000000; // Mở rộng dấu số bù hai 24 bit
}
*ketQua = (int32_t)giaTri;
return true;
}
// Đọc n mẫu, trả về trung bình và độ lệch chuẩn thực nghiệm (số đếm)
bool docNhieuMau(int n, double *trungBinh, float *lechChuan) {
double tong = 0, tongBinhPhuong = 0;
for (int i = 0; i < n; i++) {
int32_t mau;
if (!docTho(&mau)) {
return false;
}
tong += mau;
tongBinhPhuong += (double)mau * mau;
}
*trungBinh = tong / n;
double phuongSai = (tongBinhPhuong - tong * tong / n) / (n - 1);
*lechChuan = phuongSai > 0 ? sqrt(phuongSai) : 0;
return true;
}
// Khử Gauss có chọn phần tử trụ cho hệ n ẩn (n ≤ 3), ma trận mở rộng a[n][n+1]
bool giaiHe(double a[3][4], int n, double nghiem[3]) {
for (int cot = 0; cot < n; cot++) {
int hangTru = cot;
for (int h = cot + 1; h < n; h++) {
if (fabs(a[h][cot]) > fabs(a[hangTru][cot])) {
hangTru = h;
}
}
if (fabs(a[hangTru][cot]) < 1e-12) {
return false;
}
for (int k = 0; k <= n; k++) {
double tam = a[cot][k];
a[cot][k] = a[hangTru][k];
a[hangTru][k] = tam;
}
for (int h = 0; h < n; h++) {
if (h != cot) {
double f = a[h][cot] / a[cot][cot];
for (int k = cot; k <= n; k++) {
a[h][k] -= f * a[cot][k];
}
}
}
}
for (int i = 0; i < n; i++) {
nghiem[i] = a[i][n] / a[i][i];
}
return true;
}
// Bình phương tối thiểu: khối lượng theo số đếm, đa thức bậc 1 hoặc 2
bool khopDaThuc(int bac, double c[3]) {
int n = bac + 1;
double a[3][4] = {{0}};
for (int i = 0; i < soDiem; i++) {
double u = demDiem[i] / TI_LE_X;
double luyThua[5] = {1, u, u * u, u * u * u, u * u * u * u};
for (int r = 0; r < n; r++) {
for (int k = 0; k < n; k++) {
a[r][k] += luyThua[r + k];
}
a[r][n] += luyThua[r] * khoiLuongDiem[i];
}
}
c[2] = 0;
return giaiHe(a, n, c);
}
double tinhKhoiLuong(double dem, const double c[3]) {
double u = dem / TI_LE_X;
return c[0] + c[1] * u + c[2] * u * u;
}
void luuHeSo() {
boNho.begin("hieuchuan", false);
boNho.putBytes("heso", heSo, sizeof(heSo));
boNho.putInt("bac", bacDaThuc);
boNho.putFloat("g", giaTocDiaPhuong);
boNho.end();
}
void napHeSo() {
boNho.begin("hieuchuan", true);
if (boNho.getBytesLength("heso") == sizeof(heSo)) {
boNho.getBytes("heso", heSo, sizeof(heSo));
bacDaThuc = boNho.getInt("bac", 1);
giaTocDiaPhuong = boNho.getFloat("g", giaTocDiaPhuong);
Serial.println("Da nap he so hieu chuan da luu");
} else {
Serial.println("Chua co he so luu: dung he so gan dung, can hieu chuan");
}
boNho.end();
}
void lenhTruBi() {
double tb;
float s;
if (!docNhieuMau(SO_MAU_DIEM, &tb, &s)) {
Serial.println("Khong doc duoc HX711");
return;
}
diemKhong = (int32_t)lround(tb);
if (soDiem > 0) {
soDiem = 0; // Điểm cũ tính theo bì cũ nên không dùng chung được
Serial.println("Da xoa cac diem cu vi bi da doi");
}
Serial.printf("Da tru bi: diem khong = %ld (s = %.1f dem)\n", (long)diemKhong, s);
}
void lenhGhiDiem(float gam) {
if (soDiem >= SO_DIEM_TOI_DA) {
Serial.println("Da du so diem toi da");
return;
}
double tb;
float s;
if (!docNhieuMau(SO_MAU_DIEM, &tb, &s)) {
Serial.println("Khong doc duoc HX711");
return;
}
khoiLuongDiem[soDiem] = gam;
demDiem[soDiem] = tb - diemKhong;
lechChuanDiem[soDiem] = s;
soDiem++;
Serial.printf("Diem %d: %.1f g -> %.1f dem (s = %.1f dem)\n", soDiem, gam, tb - diemKhong, s);
}
void lenhLietKe() {
for (int i = 0; i < soDiem; i++) {
Serial.printf("Diem %d: %.1f g -> %.1f dem (s = %.1f dem)\n", i + 1, khoiLuongDiem[i], demDiem[i], lechChuanDiem[i]);
}
Serial.printf("Tong %d diem\n", soDiem);
}
void lenhKhop() {
if (soDiem < 3) {
Serial.println("Can it nhat 3 diem, nen co 0 g va gan toan thang");
return;
}
double c1[3], c2[3];
if (!khopDaThuc(1, c1)) {
Serial.println("Khong khop duoc: kiem tra cac diem");
return;
}
bool coBac2 = soDiem >= 4 && khopDaThuc(2, c2);
double tong1 = 0, tong2 = 0;
Serial.println("Diem | Qua can (g) | Dem | Du bac 1 (g) | Du bac 2 (g)");
for (int i = 0; i < soDiem; i++) {
double du1 = khoiLuongDiem[i] - tinhKhoiLuong(demDiem[i], c1);
double du2 = coBac2 ? khoiLuongDiem[i] - tinhKhoiLuong(demDiem[i], c2) : 0;
tong1 += du1 * du1;
tong2 += du2 * du2;
Serial.printf("%4d | %11.1f | %10.1f | %12.2f | %12.2f\n", i + 1, khoiLuongDiem[i], demDiem[i], du1, du2);
}
double s1 = sqrt(tong1 / (soDiem - 2));
double s2 = coBac2 ? sqrt(tong2 / (soDiem - 3)) : 0;
Serial.printf("Do lech chuan phan du: bac 1 = %.2f g, bac 2 = %.2f g\n", s1, s2);
// Chỉ dùng bậc 2 khi nó giảm phần dư rõ rệt, tránh khớp theo nhiễu
bacDaThuc = (coBac2 && s2 < 0.5 * s1) ? 2 : 1;
for (int k = 0; k < 3; k++) {
heSo[k] = bacDaThuc == 2 ? c2[k] : c1[k];
}
luuHeSo();
Serial.printf("Chon bac %d: b0 = %.3f g, b1 = %.6e g/dem, b2 = %.3e g/dem^2 (da luu)\n",
bacDaThuc, heSo[0], heSo[1] / TI_LE_X, heSo[2] / (TI_LE_X * TI_LE_X));
}
void inHuongDan() {
Serial.println("Lenh: t tru bi | d<gam> ghi diem | l liet ke | x xoa diem | k khop va luu | m bat/tat do | g<so> dat g");
}
void xuLyLenh() {
if (!Serial.available()) {
return;
}
String lenh = Serial.readStringUntil('\n');
lenh.trim();
if (lenh.length() == 0) {
return;
}
char ma = lenh.charAt(0);
String thamSo = lenh.substring(1);
thamSo.trim();
if (ma == 't') {
lenhTruBi();
} else if (ma == 'd') {
lenhGhiDiem(thamSo.toFloat());
} else if (ma == 'l') {
lenhLietKe();
} else if (ma == 'x') {
soDiem = 0;
Serial.println("Da xoa cac diem");
} else if (ma == 'k') {
lenhKhop();
} else if (ma == 'm') {
dangDo = !dangDo;
Serial.println(dangDo ? "Bat che do do" : "Tat che do do");
} else if (ma == 'g') {
float g = thamSo.toFloat();
if (g > 9.7 && g < 9.9) {
giaTocDiaPhuong = g;
luuHeSo();
Serial.printf("Da dat g = %.4f m/s2\n", giaTocDiaPhuong);
}
} else {
inHuongDan();
}
}
void setup() {
Serial.begin(115200);
pinMode(CHAN_DT, INPUT);
pinMode(CHAN_SCK, OUTPUT);
digitalWrite(CHAN_SCK, LOW);
delay(500);
Serial.println();
napHeSo();
lenhTruBi();
inHuongDan();
}
void loop() {
xuLyLenh();
if (!dangDo) {
return;
}
double tb;
float s;
if (!docNhieuMau(8, &tb, &s)) {
Serial.println("Khong doc duoc HX711");
delay(1000);
return;
}
double dem = tb - diemKhong;
// Trừ c0 để số chỉ bằng 0 ngay sau khi trừ bì
double gam = tinhKhoiLuong(dem, heSo) - heSo[0];
double newton = gam / 1000.0 * giaTocDiaPhuong;
Serial.printf("Dem %.0f | %.1f g | %.3f N\n", dem, gam, newton);
}
Một vài điểm trong chương trình:
- Độ lệch chuẩn của mỗi điểm được tính từ 32 lần đọc; nó cho biết độ lặp lại (nhiễu) của bộ cân. Nếu một điểm có s lớn bất thường, có thể quả cân chưa đứng yên hoặc khay bị rung.
- Khớp theo u = số đếm / 100000 thay vì số đếm thô, để các phần tử của hệ phương trình bình phương tối thiểu không chênh nhau quá nhiều bậc độ lớn.
- Chọn bậc: chỉ dùng bậc 2 khi độ lệch chuẩn phần dư giảm còn dưới một nửa so với bậc 1. Bậc cao hơn mà không giảm rõ phần dư thì chỉ là khớp theo nhiễu.
- Khi đo, chương trình trừ hệ số c₀ để số chỉ bằng 0 ngay sau khi trừ bì, rồi đổi ra newton bằng g tại chỗ, không dùng 9,80665.
4. Trình tự hiệu chuẩn

- Ổn định: bật nguồn 15–30 phút trước khi hiệu chuẩn để HX711 và load cell ổn định nhiệt; ghi nhiệt độ phòng.
- Gia tải sơ bộ: đặt quả cân lớn nhất (5 kg) rồi bỏ ra ba lần, giống quy trình hiệu chuẩn lực kế.
- Trừ bì: gõ
mđể tắt in liên tục cho dễ đọc, khay trống, gõt. - Ghi các điểm: đặt quả cân đúng tâm khay, chờ khoảng 30 giây cho số ổn định, gõ
dkèm khối lượng:d0,d500,d1000,d2000… tớid5000. Nên có ít nhất 5 điểm, gồm cả 0 và gần toàn thang. - Khớp và lưu: gõ
k, xem bảng phần dư; hệ số được lưu, lần bật sau tự nạp lại. - Kiểm tra: gõ
m, trừ bì, đặt một quả cân không dùng khi khớp (ví dụ 2,5 kg ghép từ 2 kg và 500 g), so số chỉ với giá trị đúng.
5. Kết quả mong đợi


Đọc bảng phần dư theo cách sau:
- Phần dư bậc 1 có dạng vòm có hệ thống (dương ở hai đầu, âm ở giữa) nghĩa là đặc tuyến cong; bậc 2 sửa được.
- Phần dư nhảy ngẫu nhiên cả ở bậc 1 lẫn bậc 2 nghĩa là nhiễu, rung hoặc gá chưa chắc; khớp bậc cao hơn không giúp gì, phải sửa phần cơ khí.
- Một điểm lệch hẳn so với các điểm khác: thường do nhập nhầm khối lượng hoặc quả cân chưa đứng yên; xóa và ghi lại.
- Phép kiểm tra với 2,5 kg cho 2500,0 g: hệ số tốt ở cả điểm không dùng khi khớp.
6. Thử thêm để biết bộ cân tốt đến đâu
- Lệch tâm: đặt cùng quả cân ở bốn góc và giữa khay, ghi số chỉ. Chênh lệch giữa các vị trí là sai số do vị trí đặt vật.
- Lặp lại: đặt và nhấc cùng quả cân 10 lần, tính độ lệch chuẩn của số chỉ.
- Trôi và rão: để quả cân trên khay 30 phút, ghi số chỉ mỗi 5 phút; bỏ quả cân, xem điểm không trở về ra sao.
- Nhiệt độ: ghi điểm không và số chỉ với quả cân vào lúc sáng và lúc trưa, kèm nhiệt độ phòng.
- Chiều giảm tải: sau điểm 5 kg, ghi tiếp các điểm khi bỏ dần quả cân để thấy độ trễ.
7. Độ không đảm bảo của bộ cân sau hiệu chuẩn

Sau khi khớp đa thức, phần dư chỉ còn khoảng 0,05 g, nhưng điều đó không có nghĩa bộ cân chính xác tới 0,05 g. Khi dùng thật còn có nhiệt độ thay đổi, độ trôi theo thời gian và vật đặt không đúng tâm. Với các mức ước lượng trong bảng, độ không đảm bảo mở rộng khoảng 1,4 g ở 2,5 kg. Muốn con số này có cơ sở, thay các ước lượng bằng kết quả của những phép thử ở mục 6.
Cũng cần nhớ: bộ cân được hiệu chuẩn bằng quả cân tại chỗ nên đo khối lượng trực tiếp, không phụ thuộc g. Khi cần lực, nhân khối lượng với g tại nơi đặt (lệnh g); dùng 9,80665 sẽ sai khoảng 0,2–0,25 % ở Việt Nam.
8. Xử lý lỗi
| Hiện tượng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Khong doc duoc HX711 | Sai chân DT, SCK, mất nguồn | Kiểm tra dây, chân GPIO32, GPIO33 và 3V3 |
| Số đếm giảm khi đặt quả cân | A+ và A− nối ngược hoặc load cell lắp ngược chiều | Đổi chỗ A+, A− hoặc lắp đúng chiều mũi tên |
| s của mỗi điểm lớn (vài trăm đếm) | Rung, dây lỏng, nguồn USB nhiễu | Đặt trên bàn chắc, dây ngắn, thử nguồn khác |
| Phần dư lớn và ngẫu nhiên | Gá lỏng, khay chạm vật khác | Siết lại gá, kiểm tra khe hở quanh khay |
| Can it nhat 3 diem | Chưa ghi đủ điểm | Ghi thêm điểm rồi gõ k |
| Số chỉ trôi dần sau khi bật | Chưa ổn định nhiệt | Chờ 15–30 phút trước khi trừ bì |
9. Xem thêm
- Cảm biến lực (load cell): cấu tạo, nguyên lý, cách chế tạo và tự đọc HX711 bằng ESP32.
- Hướng dẫn làm cân điện tử với ESP32, HX711 và load cell trên Arduino IDE.
- Các bài khác trong loạt: lực kế chuẩn theo ISO 376, các nguồn sai số khi đo lực, độ không đảm bảo đo theo GUM.
Xem thêm