본문 바로가기
아두이노

0.2초(5HZ) 단위로 동작하는 차량용 GPS HUD 제작

by 구루가 되고픈 2026. 1. 30.

차에서 사용할 GPS HUD를 제작하였습니다.

원래의 구상은 차량의 OBD2정보를 가져와서 표시하는 거였으나 전기자동차(EV)는 ODB2커넥터를 통해 CAN-H, CAN-L신호가 출력이 안되는 경우도 있고 표준 PID를 준수하는 경우도 많지 않아 GPS 방식으로 구현하였습니다.

 

그간 GPS를 활용하는 프로젝트는 몇번 진행했지만 HUD는 GPS를 사용하지 않은 이유가 일반적으로 GPS는 데이터 수신을 1초에 1회씩(1Hz) 하기 때문에 자동차 속도를 표시하는 상황에서는 실시간이라고 보기는 어렵습니다.

 

그래서 이번 프로젝트에서는 차량속도를 실시간으로 보여주는데 가장 주안점을 두고 진행하였습니다.

코딩은 AI의 도움을 받아서 코드 대부분이 AI가 작성해준 코드를 기반으로 하였습니다.

 

차량에 설치된 모습니다.

 

 

3개의 액정으로 구성되어 있고 각 액정마다 다음의 정보를 표시합니다.

 

- 액정1 : 차량속도

- 액정2 : 주행거리, 주행시간(GPS신호가 수신된때부터 거리, 시간 카운트)

- 액정3 : 주행방향, 차량평균속도(계산치)

 

주행시의 HUD작동영상입니다.

 

<주간>

https://youtu.be/eF3aGn8zRmA 

 

 

<야간>

https://youtu.be/_jXIeXFYwWg

 

 

0.2초(5HZ)로 속도를 표시하고 있어서 실시간으로 속도를 표시해 주고 있습니다.

이동속도, 평균속도 등은 계산치로 추가정보로 표시하고 있어 대시보드 역할도 하고 하고 있습니다.

 

GPS HUD를 젝하기 위해 사용한 부품은 아래와 같습니다.

 

- 아두이노 micro pro 보드 4개

- NEO 6M GPS수신 모듈 1개

- 1.3인치 OLED 디스플레이 3개

 

회로 구성은 보드1(이하 송신부)에서 GPS신호를 수신하여 데이터를 추출 후 보드2, 3, 4(이하 수신부)로 보내고 보드2,3,4에는 OLED디스플레이가 연결되어 있어 각각의 정보를 표시합니다.

 

- 보드1(송신부_GPS연결): GPS신호 파싱, 데이터 처리, software serial로 수신부로 추출데이터 송신

- 보드2(수신부_OLED연결): software serial로 수신, 데이터추출, OLED 표시 (차량속도)

- 보드3(수신부_OLED연결): software serial로 수신, 데이터추출, OLED 표시 (경과시간, 이동거리)

- 보드4(수신부_OLED연결): software serial로 수신, 데이터추출, OLED 표시 (진행방향, 전체 평균속도)

 

송신부의 핵심은 GPS수신을 5HZ(0.2초)로 해서 데이터를 추출(파싱)하고 수신부로 보내야 한다는 것입니다.

다행히 아두이노용으로 많이 사용되는 NEO 6M수신기는 스펙상 5HZ로 동작이 가능한 수신기라 충분히 구현이 가능했습니다.

 

수신기가 5HZ로 동작하려면 애초에 하드웨어 설정을 바꾸는 방법과 아두이노 코드에서 5HZ동작 코드를 구현하는 방법이 있는데, 후자쪽으로 처리하였습니다.

 

하드웨어 설정을 바꾸는것은 제조사의 전용 프로그램을 사용해야 하는 불편이 있습니다.

(그래도 한번 설정으로 아두이노 코드가 간단해지는 장점도 있고요)

 

코딩과 동작 테스트를 위해 브레드보드로 회로를 구성해 개발, 테스트를 반복하였습니다.

코딩 대부분은 AI를 활용하여 매우 쉽게 진행하였습니다.

 

회로는 보드1의 tx를 보드 2,3,4에 10번 핀에 연결하여 software serial로 데이터를 전달하였고, 그외 하드웨어 연결은 특별한 부분이 없습니다.

 

GPS수신기에서 0.2 단위로 GPS데이터 들이 쏟아져 들어오기 때문에 속도를 최대한 높이는 코드가 구현되어 있습니다.

 

 

Google Gemini를 활용하여 코드를 작성, 수정하고 여러차례 테스트를 진행하였습니다.

 

프로그램 완성 후 3D프린터로 케이스를 모델링해 출력해 주었습니다.

출력은 내열성이 상대적으로 낫은 ABS를 사용하였습니다.

(PLA는 한여름에 흘러내림)

 

 

점퍼선으로 회로를 구성하고 케이스내에 부품들을 배치시켜 줍니다.

 

 

전원은 분리가 가능하도록 USB-C케넉터에 JST-PH 2핀 커넥터로 연결해 주었습니다.

 

차 대시보드 위 전면유리에 최대한 가깝게 위치시켜 주었고 전원선은 모서로 부분에 천테입으로 마감해서 숨겼습니다.

 

운전석에서 보는 시야는 이렇습니다.

 

 

 

하드웨어 부분은 여기까지이며 아두이노 소스코드는 다음과 같습니다.

 

1) 송신부 - GPS수신, 데이터 송신

 

#include <SoftwareSerial.h>
#include <TinyGPS++.h>

// 1. 핀 설정
// GPS 수신: RX=8, TX=9 (SoftwareSerial)
SoftwareSerial gpsSerial(8, 9); 
TinyGPSPlus gps;

// 5Hz 설정을 위한 바이너리 명령어 (NEO-6M 전용)
const byte set5Hz[] = {
  0xB5, 0x62, 0x06, 0x08, 0x06, 0x00, 0xC8, 0x00, 0x01, 0x00, 0x01, 0x00, 0xDE, 0x6A
};

// 2. 주행 통계 변수
double totalDistKm = 0.0;
double lastLat = 0.0, lastLon = 0.0;
float maxSpeed = 0.0, avgSpeed = 0.0;
bool firstFixFound = false, stabilizationDone = false;
unsigned long firstFixTime = 0, driveStartTime = 0;

void setup() {
  // 수신부 전송용: 하드웨어 Serial1 사용 (Pro Micro의 0, 1번 핀)
  // SoftwareSerial보다 훨씬 정밀하고 데이터 깨짐이 없습니다.
  Serial1.begin(38400); 
  
  // GPS 초기 설정
  gpsSerial.begin(9600);
  delay(500);

  // A. GPS 속도를 38400으로 높임
  gpsSerial.println(F("$PUBX,41,1,0007,0003,38400,0*20"));
  gpsSerial.flush();
  delay(100);
  gpsSerial.end();
  gpsSerial.begin(38400);
  delay(100);

  // B. 5Hz 명령 전송 및 불필요 문장 제거 (대역폭 최적화)
  gpsSerial.write(set5Hz, sizeof(set5Hz));
  delay(100);
  gpsSerial.println(F("$PUBX,40,GLL,0,0,0,0,0,0*5C"));
  gpsSerial.println(F("$PUBX,40,GSV,0,0,0,0,0,0*59"));
  gpsSerial.println(F("$PUBX,40,GSA,0,0,0,0,0,0*4E"));
}

void loop() {
  // GPS 데이터 수신 및 파싱
  while (gpsSerial.available() > 0) {
    if (gps.encode(gpsSerial.read())) {
      // 새로운 위치 정보가 들어올 때마다(초당 5번) 실행
      if (gps.location.isUpdated()) {
        processStatistics();
        broadcastData();
      }
    }
  }
}

// 주행 정보 계산 로직
void processStatistics() {
  if (!gps.location.isValid()) return;

  if (!firstFixFound) {
    firstFixTime = millis();
    firstFixFound = true;
    return;
  }

  // 30초 안정화 대기
  if (!stabilizationDone) {
    if (millis() - firstFixTime > 30000) {
      lastLat = gps.location.lat();
      lastLon = gps.location.lng();
      driveStartTime = millis();
      stabilizationDone = true;
    }
    return;
  }

  float currentSpd = gps.speed.kmph();
  if (currentSpd >= 2.0 && currentSpd <= 180.0) {
    double d = TinyGPSPlus::distanceBetween(gps.location.lat(), gps.location.lng(), lastLat, lastLon);
    totalDistKm += (d / 1000.0);
    lastLat = gps.location.lat();
    lastLon = gps.location.lng();
    if (currentSpd > maxSpeed) maxSpeed = currentSpd;
  }

  float elapsedHours = (millis() - driveStartTime) / 3600000.0;
  if (elapsedHours > 0) avgSpeed = (float)(totalDistKm / elapsedHours);
}

// 수신부로 패킷 전송
void broadcastData() {
  // linkSerial 대신 Serial1(하드웨어 시리얼)을 사용하여 중단 없는 전송 보장
  Serial1.print('$');
  Serial1.print(gps.altitude.meters(), 1); Serial1.print(','); // 0
  Serial1.print(gps.speed.kmph(), 1);    Serial1.print(','); // 1
  Serial1.print(gps.course.deg(), 0);     Serial1.print(','); // 2
  Serial1.print(gps.satellites.value());  Serial1.print(','); // 3
  Serial1.print(totalDistKm, 2);          Serial1.print(','); // 4
  Serial1.print(gps.date.value());        Serial1.print(','); // 5
  Serial1.print(gps.time.value());        Serial1.print(','); // 6
  Serial1.print(avgSpeed, 1);             Serial1.print(','); // 7
  Serial1.println(maxSpeed, 1);                               // 8
}

 

2) 수신부 보드2 - 차량속도

 

#include <U8g2lib.h>
#include <SoftwareSerial.h>
#include <math.h>

SoftwareSerial linkSerial(10, 16);
U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0);

char rxBuf[100];
float currentSpd = 0;
unsigned long lastRecvTime = 0;

void setup() {
  linkSerial.begin(38400);
  linkSerial.setTimeout(10);
  u8g2.begin();
  u8g2.setBusClock(400000);
}

void loop() {
  if (linkSerial.available() > 0 && linkSerial.read() == '$') {
    int len = linkSerial.readBytesUntil('\n', rxBuf, sizeof(rxBuf) - 1);
    rxBuf[len] = '\0';
    strtok(rxBuf, ","); // Alt 건너뛰기
    char *ptr = strtok(NULL, ",");
    if (ptr) {
      currentSpd = atof(ptr);
      lastRecvTime = millis();
      updateDisplay();
    }
  }
  if (millis() - lastRecvTime > 2000) showWaiting();
}

void updateDisplay() {
  int displayVal = (currentSpd <= 1.0) ? 0 : (int)(currentSpd + 0.99);
  char valStr[10];
  itoa(displayVal, valStr, 10);
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_inb53_mn);
  u8g2.drawStr(127 - u8g2.getStrWidth(valStr), 56, valStr); 
  u8g2.sendBuffer();
}

void showWaiting() {
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_emoticons21_tr);
  u8g2.drawGlyph(18, 55, 0x0029);

  u8g2.setFont(u8g2_font_6x10_tf);
  u8g2.drawStr(48, 50, "GPS WATING");
  u8g2.sendBuffer();
}

 

3) 수신부 보드3 - 이동거리, 주행시간

#include <U8g2lib.h>
#include <SoftwareSerial.h>

SoftwareSerial linkSerial(10, 16);
U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0);

char rxBuf[100];
float distanceKm = 0;
unsigned long startTime = 0;
bool isStarted = false;

void setup() { 
  linkSerial.begin(38400); 
  linkSerial.setTimeout(10);
  u8g2.begin(); 
  u8g2.setBusClock(400000); 
}

void loop() {
  if (linkSerial.available() > 0 && linkSerial.read() == '$') {
    int len = linkSerial.readBytesUntil('\n', rxBuf, sizeof(rxBuf) - 1);
    rxBuf[len] = '\0';
    if (parseData()) {
      if (!isStarted) { startTime = millis(); isStarted = true; }
      updateDisplay();
    }
  }
}

bool parseData() {
  char *p = strtok(rxBuf, ","); // Alt(0)
  for(int i=0; i<3; i++) strtok(NULL, ","); // Spd(1), Course(2), Sat(3) 건너뛰기
  p = strtok(NULL, ","); if (p) distanceKm = atof(p); // Dist(4)
  return true;
}

void updateDisplay() {
  u8g2.clearBuffer();
  
  u8g2.setFont(u8g2_font_streamline_map_navigation_t);
  u8g2.drawGlyph(4, 28, 0x0034);
  
  u8g2.setFont(u8g2_font_streamline_interface_essential_alert_t);
  u8g2.drawGlyph(4, 58, 0x0034);

  char dStr[20];
  if (distanceKm < 100)
    dtostrf(distanceKm, 1, 2, dStr);
  else
    dtostrf(distanceKm, 1, 1, dStr);

  unsigned long totalSec = (millis() - startTime) / 1000;
  char tStr[20];
  sprintf(tStr, "%d:%02d:%02d", (int)(totalSec/3600), (int)((totalSec%3600)/60), (int)(totalSec%60));

  u8g2.setFont(u8g2_font_logisoso22_tr);
  u8g2.drawStr(33, 28, dStr); 
  u8g2.drawStr(33, 58, tStr);

  u8g2.setFont(u8g2_font_6x10_tf);
  u8g2.drawStr(108, 24, "KM");

  u8g2.sendBuffer();
}

 

4) 수신부 보드4 - 주행방향, 전체평균속도

 

#include <U8g2lib.h>
#include <SoftwareSerial.h>

SoftwareSerial linkSerial(10, 16);
U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0);

char rxBuf[110];
float courseDeg = 0.0, avgSpeed = 0.0;

void setup() {
  linkSerial.begin(38400); 
  linkSerial.setTimeout(10);
  u8g2.begin();
  u8g2.setBusClock(400000);
}

void loop() {
  if (linkSerial.available() > 0 && linkSerial.read() == '$') {
    int len = linkSerial.readBytesUntil('\n', rxBuf, sizeof(rxBuf) - 1);
    rxBuf[len] = '\0';
    if (parsePacket()) updateDisplay();
  }
}

bool parsePacket() {
  char *ptr = strtok(rxBuf, ","); // Alt(0)
  ptr = strtok(NULL, ",");        // Spd(1)
  ptr = strtok(NULL, ","); if (ptr) courseDeg = atof(ptr); // Course(2)
  for(int i = 0; i < 4; i++) strtok(NULL, ","); // Sat, Dist, Date, Time 건너뛰기
  ptr = strtok(NULL, ","); if (ptr) avgSpeed = atof(ptr); // Avg(7)
  return true;
}

void updateDisplay() {
  u8g2.clearBuffer();
  
  u8g2.setFont(u8g2_font_streamline_map_navigation_t);
  u8g2.drawGlyph(4, 28, 0x0030);
  
  u8g2.setFont(u8g2_font_streamline_money_payments_t);
  u8g2.drawGlyph(4, 58, 0x0034);

  const char* directions[] = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"};
  int index = (int)((courseDeg + 22.5) / 45.0) % 8;

  // 평균 속도 표시
  char valStr[20];
  dtostrf(avgSpeed, 1, 1, valStr);

  u8g2.setFont(u8g2_font_logisoso22_tr); //폭22
  u8g2.drawStr(33, 28, directions[index]);
  u8g2.drawStr(33, 58, valStr);

  u8g2.setFont(u8g2_font_6x10_tf);
  u8g2.drawStr(108, 54, "KM");

  u8g2.sendBuffer();
}