Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 50.328 phút đọc

Đo nhiệt độ và số học dấu chấm cố định

Sau bài này bạn sẽ làm được

  • Đổi giá trị ADC sang mili vôn và độ C mà không dùng số thực
  • Hiểu định dạng Q và chọn số bit phần thập phân
  • Cài bộ lọc trung bình trượt và bộ lọc thông thấp một cực
  • Ước lượng sai số tích lũy của phép chia số nguyên

Cảm biến cho ra số nguyên, và người dùng muốn thấy 24,97 độ C. Giữa hai đầu đó là một chuỗi phép tính mà nếu làm bằng số thực thì tốn Flash và chu kỳ, còn nếu làm ẩu bằng số nguyên thì sai lệch dần theo thời gian. Bài này chỉ ra cách làm đúng.

#Vì sao tránh số thực

Phép tínhCortex-M0+Cortex-M4FCortex-M4F double
Cộng số nguyên 32 bit1 chu kỳ1 chu kỳ-
Nhân số nguyên 32 bit1 chu kỳ1 chu kỳ-
Chia số nguyên 32 bitkhoảng 30 tới 100 (gọi thư viện)2 tới 12 chu kỳ-
Cộng floatkhoảng 100 (phần mềm)1 chu kỳkhoảng 100
Nhân floatkhoảng 130 (phần mềm)1 chu kỳkhoảng 150
Chia floatkhoảng 300 (phần mềm)14 chu kỳkhoảng 400
Cortex-M0+ không có lệnh chia lẫn FPU. Cortex-M4F có FPU nhưng chỉ cho float, không cho double.
terminal
arm-none-eabi-gcc -Wdouble-promotion -c do-nhiet.c
do-nhiet.c:14:29: warning: implicit conversion from 'float' to 'double' to match other operand of binary expression [-Wdouble-promotion]
   14 |     float u = raw * 3.3 / 4095.0f;
      |                     ^~~
Cách làmFlash thêmGhi chú
Chỉ số nguyên0Không kéo thêm gì
float với FPU phần cứngkhoảng 1 KBVài hàm hỗ trợ đổi kiểu
float phần mềmkhoảng 4 tới 6 KBCả bộ libgcc số thực
printf có %fkhoảng 8 tới 12 KB nữaBộ định dạng số thực rất nặng
Con số tùy phiên bản thư viện và cờ biên dịch, hãy tự đo bằng arm-none-eabi-size.

#Từ giá trị ADC sang mili vôn

adc-doi.c
#include <stdint.h>

#define ADC_TOI_DA 4095u      /* ADC 12 bit: 0 tới 4095 */
#define VREF_MV    3300u      /* điện áp tham chiếu, mili vôn */

/* Đổi giá trị ADC sang mili vôn, làm tròn tới số gần nhất. */
uint16_t adc_sang_mv(uint16_t raw)
{
    /* raw * 3300 lớn nhất là 13 513 500, cần 32 bit */
    uint32_t tu = (uint32_t)raw * VREF_MV;

    return (uint16_t)((tu + ADC_TOI_DA / 2u) / ADC_TOI_DA);
}

Gộp cả chuỗi phép tính thành một

Cảm biến nhiệt độ trong STM32F4 cho ra điện áp 760 mV ở 25 độ C và thay đổi 2,5 mV mỗi độ. Cách ngây thơ là đổi sang mili vôn rồi mới tính nhiệt độ, nhưng như vậy mất phần lẻ ngay ở bước đầu.

Chia rồi mới nhân
int32_t nhiet_centi(uint16_t raw)
{
    uint16_t mv = adc_sang_mv(raw);       /* MAT PHAN LE O DAY */

    /* 2.5 mV moi do => 40 centi do moi mV */
    return ((int32_t)mv - 760) * 40 + 2500;
}

/* Do phan giai bi ket o 40 centi do = 0.4 do C.
   Ket qua chi nhay theo buoc 0.4 do, du ADC min hon nhieu. */
Nhân hết rồi chia một lần
int32_t nhiet_centi(uint16_t raw)
{
    /* raw * 3300 / 4095 * 40 - 760*40 + 2500
       = raw * 132000 / 4095 - 27900

       4095 * 132000 = 540 540 000 < 2^31, khong tran */

    return (int32_t)((uint32_t)raw * 132000u / 4095u) - 27900;
}
terminal
./thu-nhiet
raw   mV that   T that     chia som   gop lai
943    759.93   24.971       24.60     24.97
975    785.71   35.286       35.00     35.28
1000   805.86   43.344       43.40     43.34
1024   825.20   51.081       51.00     51.08

#Định dạng Q

Định dạng Q
Cách biểu diễn số thực bằng số nguyên: Qm.n nghĩa là dùng m bit cho phần nguyên và n bit cho phần lẻ. Giá trị thật bằng số nguyên lưu trong biến chia cho 2^n.
Định dạngKiểu chứaDải giá trịĐộ phân giảiDùng cho
Q8.8int16_t-128,000 tới 127,9960,00391Nhiệt độ, phần trăm
Q16.16int32_t-32768,0 tới 32767,999980,0000153Bộ lọc, tọa độ
Q4.12int16_t-8,0 tới 7,999760,000244Hệ số lọc chuẩn hóa
Q0.31int32_t-1,0 tới 0,999999999530,00000000047Tín hiệu âm thanh đã chuẩn hóa
q16.h
#ifndef Q16_H
#define Q16_H

#include <stdint.h>

typedef int32_t Q16;                 /* Q16.16 */

#define Q16_MOT     (1 << 16)
#define Q16_TU_INT(x)   ((Q16)(x) << 16)
#define Q16_SANG_INT(q) ((int32_t)(q) >> 16)

static inline Q16 q16_cong(Q16 a, Q16 b) { return a + b; }
static inline Q16 q16_tru (Q16 a, Q16 b) { return a - b; }

/* Nhân: hai số Q16.16 nhân nhau ra Q32.32, phải dịch phải 16 */
static inline Q16 q16_nhan(Q16 a, Q16 b)
{
    return (Q16)(((int64_t)a * (int64_t)b) >> 16);
}

/* Chia: dịch trái số bị chia 16 bit TRƯỚC khi chia */
static inline Q16 q16_chia(Q16 a, Q16 b)
{
    return (Q16)((((int64_t)a) << 16) / b);
}

#endif

Khi nào cần định dạng Q và khi nào không

  • Không cần nếu bạn chỉ đổi đơn vị một lần, như từ ADC sang mili vôn. Nhân rồi chia một lần là đủ, và dễ đọc hơn.
  • Cần nếu có phép tính lặp lại nhiều lần trên cùng biến, như bộ lọc, bộ tích phân PID, hay tính vị trí từ vận tốc. Ở đó phần lẻ phải được giữ lại giữa các lần lặp.
  • Cần nếu hệ số nhân nhỏ hơn 1. Số nguyên không biểu diễn được 0,3 nên phải nhân với 0,3 * 65536 = 19661 rồi dịch phải 16.

#Trung bình trượt và thông thấp một cực

Trung bình trượt cửa sổ lũy thừa của hai

tb-truot.c
#include <stdint.h>

#define CUA_SO 8u             /* PHẢI là lũy thừa của 2 */
#define DICH   3u             /* log2(CUA_SO) */

typedef struct {
    uint16_t vong[CUA_SO];
    uint32_t tong;
    uint8_t  vi;
    uint8_t  da_day;
} TbTruot;

void tb_khoi_tao(TbTruot *t, uint16_t gia_tri_dau)
{
    for (uint8_t i = 0; i < CUA_SO; ++i) t->vong[i] = gia_tri_dau;

    t->tong   = (uint32_t)gia_tri_dau * CUA_SO;   /* nạp sẵn, tránh vọt đầu */
    t->vi     = 0u;
    t->da_day = 1u;
}

uint16_t tb_them(TbTruot *t, uint16_t x)
{
    t->tong -= t->vong[t->vi];      /* bỏ mẫu cũ nhất */
    t->vong[t->vi] = x;
    t->tong += x;                   /* thêm mẫu mới */
    t->vi = (uint8_t)((t->vi + 1u) & (CUA_SO - 1u));

    return (uint16_t)(t->tong >> DICH);
}

Thông thấp một cực

loc-mot-cuc.c
#include <stdint.h>

/* y[n] = y[n-1] + (x[n] - y[n-1]) / 2^K

   Chỉ cần MỘT biến trạng thái, không cần mảng.
   Nhưng phải giữ phần lẻ, nếu không bộ lọc sẽ dừng sớm. */

#define K 4u                        /* hệ số làm mượt */

typedef struct {
    int32_t tich_luy;               /* giá trị nhân với 2^K, tức Q(K) */
} LocMotCuc;

void loc_khoi_tao(LocMotCuc *l, int32_t gia_tri_dau)
{
    l->tich_luy = gia_tri_dau << K;
}

int32_t loc_them(LocMotCuc *l, int32_t x)
{
    l->tich_luy += ((x << K) - l->tich_luy) >> K;

    return l->tich_luy >> K;
}
KHệ sốHằng thời gian (mẫu)Số mẫu tới 95 phần trămTần số cắt khi lấy mẫu 100 Hz
11/21,44511,03 Hz
21/43,48114,58 Hz
31/87,49232,13 Hz
41/1615,49471,03 Hz
51/3231,50950,51 Hz
61/6463,501910,25 Hz
81/256255,507660,06 Hz
Hằng thời gian tính bằng -1 / ln(1 - 1/2^K), tần số cắt bằng tần số lấy mẫu chia cho 2 pi lần hằng thời gian.

#Sai số tích lũy của phép chia số nguyên

Một máy đo quãng đường đếm xung từ encoder. Mỗi xung ứng với 7/3 mili mét. Cách viết ngây thơ mất một phần bảy quãng đường sau ba trăm xung.

Chia mỗi lần, vứt phần dư
uint32_t quang_duong_mm = 0;

void moi_xung(void)
{
    quang_duong_mm += 7u / 3u;      /* 7/3 = 2, mat 0.333 mm moi xung */
}

/* Sau 300 xung: 600 mm.
   Dung phai la 700 mm.
   Thieu 100 mm, tuc 14.3 phan tram. */
Mang phần dư sang lần sau
uint32_t quang_duong_mm = 0;
uint32_t du            = 0;

void moi_xung(void)
{
    du += 7u;
    quang_duong_mm += du / 3u;
    du %= 3u;                       /* GIU LAI phan du cho lan sau */
}

/* Sau 300 xung: 700 mm. Dung chinh xac.
   Sai so khong bao gio vuot 1 mm, va khong tich luy. */
terminal
./thu-encoder
so xung   vut du   mang du   dung
     30       60        70     70
    300      600       700    700
   3000     6000      7000   7000
  30000    60000     70000  70000

Sai so cua cach vut du tang tuyen tinh theo so xung.

#In số dấu chấm cố định

in-centi.c
#include <stdint.h>

#include "uart.h"

/* In giá trị centi độ, ví dụ 2497 thành "24.97" */
void in_centi(int32_t v)
{
    if (v < 0) {
        uart_gui_byte('-');
        v = -v;
    }

    char     dem[12];
    uint8_t  n   = 0u;
    uint32_t phn = (uint32_t)v / 100u;
    uint32_t le  = (uint32_t)v % 100u;

    if (phn == 0u) dem[n++] = '0';

    while (phn > 0u) {
        dem[n++] = (char)('0' + phn % 10u);
        phn /= 10u;
    }

    while (n > 0u) uart_gui_byte((uint8_t)dem[--n]);

    uart_gui_byte('.');
    uart_gui_byte((uint8_t)('0' + le / 10u));
    uart_gui_byte((uint8_t)('0' + le % 10u));
}
terminal
./thu-in
  2497 ->  24.97
   100 ->   1.00
     5 ->   0.05
     0 ->   0.00
    -5 ->  -0.05
  -350 ->  -3.50
 -4012 -> -40.12

Tự làm thử

  1. Viết adc_sang_mv có làm tròn và so sánh kết quả với bảng ở trên cho các giá trị 0, 1024, 2048, 3000, 4095.
  2. Gộp chuỗi tính nhiệt độ thành một phân số duy nhất và kiểm tra tử số lớn nhất không tràn uint32_t.
  3. Cài bộ lọc thông thấp một cực có bộ tích lũy, rồi bỏ bộ tích lũy đi và đo xem nó dừng ở đâu với đầu vào 1000 và K = 4.
  4. Cài bộ đếm quãng đường với hệ số 7/3, chạy 3000 xung theo cả hai cách và so sánh kết quả.
  5. Viết hàm in số centi hỗ trợ số âm, thử với 0, 5, -5, -350 và INT32_MIN.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Trên Cortex-M4F chỉ float chạy trên FPU, còn double chạy bằng phần mềm. Bật -Wdouble-promotion để bắt lỗi quên hậu tố f.
  • Nhân hết rồi chia một lần ở cuối, và luôn kiểm tử số lớn nhất có tràn hay không trước khi làm vậy.
  • Định dạng Qm.n lưu số thực trong số nguyên. Nhân hai số Q16.16 phải qua int64_t rồi dịch phải n bit.
  • Bộ lọc thông thấp một cực phải giữ K bit lẻ trong bộ tích lũy, nếu không nó dừng cách giá trị đúng tới 2^K - 1 đơn vị.
  • Khi cộng dồn một phân số nhiều lần, giữ lại phần dư bằng du %= b để sai số không tích lũy tuyến tính.