Chciałbym zrobić wstęp do podziału projektu na pliki w języku C. Jest to nam potrzebne też do tego, aby zrozumieć czym jest moduł lub jednostka kompilacji.
Dobre zrozumienie pojęcia “jednostka kompilacji” spowoduje to, iż później lepiej będziesz się orientował w zakresach widoczności np. zmiennych. Dodatkowo lepiej zrozumiesz czym jest kompilacja, a czym linkowanie.
Dlaczego podział na pliki?
Pisząc swój program na 20000% zacznie on zajmować sporo linii. Poruszanie się po programie mającym 1 tys. linii zaczyna być uciążliwe:
- Scrollowanie jest bardzo niewygodne
- Ciężko jest odnajdować interesujące nas rzeczy jeżeli nie trzymaliśmy się ściśle jakiegoś porządku
- …z porządkiem zresztą też
Pomyśl, iż chociażby Windows ma tych linii około miliona. Słyszałem to już kilka lat temu, więc w najnowsze pewnie mają już kilka milionów.
Jak żyć?!
Trzeba w końcu zacząć dzielić program na mniejsze moduły. Moduły te to będą zestawy plików, które później dołączamy do programu głównego, lub łączymy je między sobą.
Takie moduły możemy już znać z biblioteki standardowej! Wszystkie biblioteki zewnętrzne jak np. string.h są właśnie takimi osobnymi zestawami plików. Nazywamy je właśnie bibliotekami.
Pisząc własne programy na mikrokontrolery również będziesz pisał takie biblioteki do różnych celów. Obsługa czujnika, obsługa menu, jakaś grafika. To będą pojedyncze moduły.
Pliki w języku C
Jakie mamy pliki w C? Te najważniejsze i te, które nas interesują to pliki z rozszerzeniem *.c i *.h.
Pliki H
Jest to header, czyli nagłówek. W tym miejscu będą się znajdowały jedynie prototypy i deklaracje.
Plik taki nie ma kodu wykonywalnego. Albo inaczej… nie chcemy, aby miał
W nim będziemy pisali tzw. interfejs. Będzie to tylko to, czym możemy się posługiwać używając tego pliku lub ściślej mówiąc biblioteki.
Mówiąc po ludzku… Tutaj będą znajdowały się takie rzeczy jak deklaracje:
- typów
- enumów
- struktur
- funkcji, czyli same prototypy
- zmiennych globalnych (o ile będziemy je chcieli ;))
Nie będzie tutaj kodu wykonywalnego, czyli ciał funkcji.
Zestaw takich deklaracji dołączymy do drugiego rodzaju pliku (z rozszerzeniem *.c). Na przykład do pliku main.c.
Potrzebujemy wykorzystać do tego celu preprocesor.
#include „plik.h”
#include <plik.h>
W ostrym nawiasie <plik.h> wskazujesz plik ze ścieżki domyślnej kompilatora.
W cudzysłowie podajesz plik ze ścieżki projektu.
Pliki C
Pliki z rozszerzeniem *.c to pliki źródłowe. w uproszczeniu źródła.
To jest miejsce na adekwatny kod. Tutaj piszemy ciała funkcji.
Do tego pliku również dołączamy inne nagłówki jak, chociażby nagłówek główny main.h lub stdio.h.
Plik ten musi znać definicje typów, na których pracuje, oraz inne nagłówki, które będą wymagane do skompilowania tego pliku.
Oprócz funkcji możemy umieścić tu zmienne globalne. Będą one mogły być widoczne globalnie, czyli w całym projekcie. Tylko… to będzie rzadkość i ostateczność tak naprawdę. W dobrej praktyce raczej unikamy zmiennych globalnych. Kiedyś o tym opowiem więcej.
Jeśli nie globalne, to jakie? Globalne o ograniczonym zasięgu do tego konkretnego pliku. To jest dużo lepsza i częstsza praktyka
Chyba będę musiał wcześniej opowiedzieć o tych globalach. Daj znać, czy zrobić to w następnych mailach.
Minimum dla kompilatora
Może to się wyda zaskoczenie, ale trzeba wiedzieć, iż kompilator do skompilowania pliku potrzebuje tylko definicji.
Nie potrzebuje mieć konkretnej zmiennej zaalokowanej w pamięci.
Nie potrzebuje też ciała funkcji! Wystarczy mu sam prototyp.
Możesz spytać jak to?! Słowo klucz: na etapie kompilacji.
To jest całkiem śmieszne, ale kompilacja jako cały proces od A do Z dzieli się na kilka etapów. Najważniejsze z nich to:
- Preprocesor
- Kompilacja
- Linkowanie
Tak! Kompilacja jest częścią kompilacji! Ekstra, co nie?
I to właśnie do kroku nr 2 potrzebna jest wiedza tylko o prototypach. Dlaczego?
Bo ten etap kompiluje TYLKO jeden plik C jednocześnie. Z jednego pliku tworzy jeden tzw. plik obiektowy. Tworzą się takie moduły. Jest to pojedyncza jednostka kompilacji.
W nim (tym pliku obiektowym) znajdują się (mówiąc w ogromnym uproszczeniu) informacje o tym, iż chcemy użyć zmiennej X w jakimś miejscu lub funkcji Y z argumentami Z i W w takim miejscu programu.
Kompilacja tworzy więc (znowu w ogromnym skrócie) mapę powiązań co, kiedy, w jaki sposób i z jakimi operacjami robić. Goły asembler bez powiązań z adresami w pamięci.
Nie musi znać funkcji, ale wie, iż taką trzeba wywołać. Teraz kto spina te informacje? LINKER na etapie linkowania.
Linker na wejściu zbiera wszystkie pliki obiektowe po kompilacji i tworzy z nich całość. Rozwiązuje te powiązania zmiennych i funkcji między modułami i przypisuje im konkretne miejsca w pamięci np. mikrokontrolera.
Widzi na przykład, iż Moduł1 chciał wywołać funkcję znajdująca się w Moduł2. Łączy więc wywołanie z funkcją znajdującą się w INNEJ jednostce kompilacji.
To będzie ważne, aby zrozumieć istotę działania. Bo taki prototyp co mówi kompilatorowi?
“Mam taką funkcję i chcę ją użyć. Nie mam ciała – to rozwiąże linker”
Kompilator ustawi instrukcje, argumenty na stosie, zwrotkę z funkcji i tyle. Teraz linker musi podłączyć te operacje z odpowiednią funkcją w innej jednostce kompilacji.
I tutaj dochodzimy to tego, iż zarówno kompilator jak i linker będzie zgłaszał inne problemy.
Kompilator powie Ci najczęściej, iż nie zna nazwy zmiennej lub funkcji przy ich użyciu. Oznacza to, iż nigdy wcześniej nie widział prototypu funkcji lub definicji zmiennej. Trzeba go poinformować tylko i wyłącznie o istnieniu (nazwie i typach). Ciało funkcji lub rezerwacja pamięci dla zmiennej może być w innej jednostce kompilacji. To go już nie interesuje.
Co zgłasza najczęściej linker? Że nie może rozwiązać powiązania. Taka sytuacja będzie, jeżeli w jednej jednostce kompilacji powiadomimy prototypem o istnieniu funkcji, której… ciała nie będzie nigdzie indziej (w innych jednostkach kompilacji). Linker nie rozwiązał powiązania i zwrócił błąd.
To są dwa zupełnie różne komunikaty i inaczej trzeba do nich podchodzić.
Podsumowanie
Trochę się rozpisałem, a przez cały czas czuję, iż nie wszystko powiedziałem… Daj mi znać, jeżeli chciałbyś czegoś dłuższej i bardziej rozbudowanej formie.
Nie zrobiliśmy, chociażby treningu, czyli tego jak to ma faktycznie wyglądać np. w kompilatorze online. Tekstowo może być to ciężkie do zrealizowania. Może wolałbyś naukę w formie wideo? Mam w takim razie pewną propozycję
Chcesz się nauczyć języka C z myślą o mikrokontrolerach?
Stworzyłem kurs dedykowany mikrokontrolerom. Uczę w nim języka C od podstaw. Wszystko to, co omówiłem w tym wpisie (i wiele, wiele więcej) znajduje się w programie kursu.
Zebrałem swoje doświadczenie z kilku lat programowania embedded i chcę przekazać Ci jak najlepszą wiedzę. Uczestniczyłem w różnych projektach: samodzielnie, start-up, średnia firma i olbrzymia korporacja.
Oprócz podstaw i składni przekazuję masę dobrych praktyk. Wplatam to między tłumaczenie kolejnych aspektów języka C.
Dodatkowym atutem jest również to, iż pokazuję jak można dobrze prowadzić projekt. Pokażę Ci jak radzić sobie z budowaniem warstw abstrakcji. Skorzystamy przy tym ze struktur, wskaźników i callbacków. No i oczywiście podział na pliki. To wiele pomaga.
Takie odseparowane warstwy dużo łatwiej dają się przenosić między projektami, a choćby między różnymi rodzinami mikrokontrolerów.
Dołącz do listy oczekujących na kurs i zacznij naukę razem z przygotowanymi przeze mnie materiałami. Po zapisaniu się będziesz otrzymywał co tydzień maile o języku C: https://cdlamikrokontrolerow.pl