Cấu trúc chương trình (Phần 2)

(Phần 1) , (Phần 2)

5. Quá trình biên dịch chương trình

Để hiểu sâu hơn về cách module hóa chương trình, ta cần hiểu về cách chương trình được tiền xử lý, biên dịch và liên kết thành một file thực thi. Hình 2-4 mô tả sơ lược quá trình biên dịch chương trình.

Các module được biên dịch (compile) độc lập với nhau. Đầu tiên, chương trình tiền xử lý (preprocessor) sẽ duyệt file mã nguồn (.c), thực hiện các chỉ dẫn tiền biên dịch và xử lý các file header được bao gồm trong file nguồn. Sau đó, trình biên dịch sẽ biên dịch mã nguồn thành file đối tượng (object file). Trong quá trình này, nếu có lỗi về cú pháp lệnh, trình biên dịch sẽ báo lỗi.

Sau khi biên dịch thành công tất cả các file, trình liên kết (linker) sẽ liên kết các file object và các thư viện có sẵn để tạo thành một file thực thi. File này được nạp vào bộ nhớ chương trình của vi xử lý để thực thi, tùy theo phần mềm mà file này có thể có các định dạnh .hex, .bin, .srec, v.v.

Trong quá trình liên kết, linker quản lý các việc cấp phát bộ nhớ cho các đối tượng, các biến trong chương trình, đảm bảo rằng tất cả các file đối tượng đều được đặt vào trong bộ nhớ chương trình và liên kết chính xác với nhau. Các lỗi trong quá trình này thường là tổng dung lượng chương trình vượt quá dung lượng bộ nhớ chương trình của vi xử lý, tổng bộ nhớ mà các biến đòi hỏi vượt quá dung lượng RAM, hay là một module thực hiện lệnh gọi đến một hàm, mà hàm này chưa được định nghĩa trong bất kỳ thư viện hay module nào trong chương trình.

Hình 2-4: Quá trình biên dịch chương trình

6. Chỉ dẫn tiền biên dịch

Bộ tiền xử lý (pre-processor) sử dụng các chỉ dẫn tiền biên dịch (preprocessor directive) để thay đổi mã nguồn trước khi chương trình thực sự được biên dịch. Các chỉ dẫn tiền biên dịch được bắt đầu bằng dấu '#'.

6.1. Chỉ dẫn #include

Chỉ dẫn #include được sử dụng để báo cho bộ tiền xử lý gộp tất cả mã nguồn hay chỉ dẫn trong file header vào file hiện hành.

Hình 2-5 mô tả cách bộ tiền xử lý sửa đổi mã nguồn khi xử lý chỉ dẫn #include. Như đã thấy, khi gặp chỉ dẫn #include, bộ tiền xử lý chỉ đơn giản là gộp nội dung của file có tên trong chỉ dẫn vào file hiện hành.

Như đã thấy trên, sau khi tiền xử lý, trong file c.h biến a được khai báo hai lần vì nó đã được khai báo trong cả file a.hb.h. Khi biên dịch, trình biên dịch sẽ báo lỗi vì một biến không thể được khai báo hai lần.

// a.h     |// b.h          |// c.h
char a;    |#include "a.h"  |#include "a.h"
           |                |#include "b.h"

Preprocessor:
// a.h     |// b.h          |// c.h
char a;    |char a;         |char a;
           |char b;         |char a;
           |                |char b;

Hình 2-5: Chỉ dẫn #include

6.2. Chỉ dẫn #define

Chỉ dẫn #define được sử dụng để định nghĩa một cái tên có ý nghĩa cho một hằng số, một biểu thức.

#define SAMPLINGRATE    8000
#define LED     *(volatile unsigned char *)0x2000

Chỉ dẫn #define còn được dùng để định nghĩa một chuỗi, thông thường được sử dụng với các chỉ dẫn #ifndef, #ifdef.

#define DEBUG

6.3. Dùng các chỉ dẫn tiền biên dịch cho file header

Như đã nói ở phần 6.1, chương trình có thể bị lỗi khi ta khai báo #include cho nhiều file header, mà trong bản thân các file này lại đã khai báo #include file khác. Khi đó một biến hay một hàm có thể được khai báo nhiều lần, gây ra lỗi khi biên dịch. Để tránh lỗi này, các file header được khai báo theo cấu trúc sau:

#ifndef __FILE_NAME
#define __FILE_NAME
/*
all code goes here
*/
#endif

Hình 2-6: Cấu trúc file header

Hình 2-7 mô tả cách tạo ba file header a.h, b.hc.h với cấu trúc như ở Hình 2-6. Ta thấy file c.h sau khi được tiền xử lý, các biến chỉ được khai báo đúng một lần, do đó cho phép biên dịch thành công.

Header files:
a.h            |b.h             |c.h
---------------|----------------|---------------
#ifndef __A_H  |#ifndef __B_H   |#ifndef __C_H
#define __A_H  |#define __B_H   |#define __C_H
  char a;      |#include "a.h"  |#include "a.h"
#endif         |  char b;       |#include "b.h"
               |#endif          |  char c;
               |                |#endif

Preprocessor:

a.h            |b.h             |c.h
---------------|----------------|---------------
char a;        |char a;         |char a;
               |char b;         |char b;
               |                |char c;

Hình 2-7: Tạo file header với chỉ dẫn biên dịch

Ta sẽ đi sâu vào giải thích tại sao file c.h lại được sửa đổi ra dạng như vậy. Sau khi xử lý các chỉ dẫn #include, file c.h sẽ như Hình 2-8.

#ifndef __C_H
#define __C_H

#ifndef __A_H
#define __A_H
  char a;
#endif

#ifndef __B_H
#define __B_H

#ifndef __A_H
#define __A_H
  char a;
#endif
  char b;
#endif

  char c;
#endif

Hình 2-8: file c.h sau khi xử lý chỉ dẫn #include

Từ dòng số 4 đến dòng số 7 chính là file a.h, được gộp vào c.h nhờ vào lệnh #include "a.h". Chỉ dẫn #ifndef __A_H ở dòng số 4 sẽ có giá trị TRUE, vì lúc này __A_H chưa được define. Lúc này các lệnh ở giữa #ifndef __A_H#endif ở dòng số 7 sẽ được thực thi. Sau chỉ dẫn này, __A_H được define ở dòng số 15 và biến a được khai báo.

Từ dòng số 9 đến dòng số 17 là kết quả của chỉ dẫn #include "b.h" Chỉ dẫn #ifndef __B_H ở dòng số 9 sẽ có giá trị TRUE, vì lúc này __B_H chưa được define. Lúc này các lệnh ở giữa #ifndef __B_H#endif ở dòng số 17 sẽ được thực thi.

Từ dòng số 12 đến 15 là kết quả của lệnh #include "a.h" trong file b.h. Tuy nhiên, do __A_H đã được define ở trên, chỉ dẫn #ifndef __A_H sẽ có giá trị FALSE, các mã từ dòng số 12 đến 15 sẽ được bỏ qua. Cuối cùng, file c.h có kết quả như ở Hình 2-7.

6.4. Khai báo biến cho module

Ở các ví dụ trên Hình 2-7, các biến được khai báo trong file header. Với cấu trúc file header như đã nói, các biến chỉ được khai báo đúng một lần. Tuy nhiên, điều này không đảm bảo rằng chương trình có thể biên dịch được. Trên Hình 2-9, file a.c sau khi được tiền xử lý và biên dịch, trình biên dịch sẽ cấp phát một vùng nhớ cho biến tên a trong module a.o. File b.c sau khi được tiền xử lý và biên dịch, trình biên dịch sẽ cấp phát một vùng nhớ cho biến tên a và một biến tên b trong module b.o. Quá trình biên dịch sẽ xảy ra mà không có lỗi gì, tuy nhiên khi linker liên kết hai file a.ob.o, nó sẽ báo lỗi là biến a được cấp phát bộ nhớ trong cả hai module a.ob.o.

Header Files:
a.h                          |b.h             
-----------------------------|-----------------------------
#ifndef __A_H                |#ifndef __B_H
#define __A_H                |#define __B_H
  char a;                    |#include "a.h"
  void a_function(void);     |  char b;
#endif                       |  void b_function(void);
                             |#endif

Source Files:
a.c                          |b.c
-----------------------------|--------------------------
#include "a.h"               |#include "b.h"
void a_function(void)        |void b_function(void)
{                            |{
// function implementation   |// function implementation
}                            |}

Preprocessor:
a.c                          |b.c
-----------------------------|--------------------------
char a;                      |char a;
void a_function(void);       |void a_function(void);
                             |
void a_function(void)        |char b;
{                            |void b_function(void);
// function implementation   |
}                            |void b_function(void)
                             |{
                             |// function implementation
                             |}

Hình 2-9: Khai báo biến trong header file

Vì lý do nêu trên, ta hết sức tranh khai báo biến trong file header mà chỉ nên khai báo biến trong file mã nguồn (.c). File header chỉ chứa các mô tả hàm, các định nghĩa macro, kiểu… mà không chứa bất cứ khai báo biến nào. Nói cách khác, khi biên dịch, các đoạn mã trong file header không nên chiếm bất cứ ô nhớ nào trong bộ nhớ.

Hình 2-10 mô tả cách viết một file header chứa các định nghĩa và các mô tả hàm để điều khiển LED trên board Stellaris LaunchPad.

#ifndef __LED_H__
#define __LED_H__

#define LEDGREEN    GPIO_PIN_3
#define LEDBLUE     GPIO_PIN_2
#define LEDRED      GPIO_PIN_1
void LEDInit(void);
void LEDSet(int LED);
void LEDClear(int LED);
void LEDToggle(int LED);

#endif

Hình 2-10: Header file led.h

Ta thấy trong file led.h chỉ chứa các định nghĩa và mô tả các hàm sẽ thực thi trong file led.c, không hề khai báo bất cứ biến nào.

#include "led.h"
#include "inc/hw_memmap.h"
#include "inc/hw_types.h"
#include "driverlib/gpio.h"
#include "driverlib/pin_map.h"
#include "driverlib/sysctl.h"

static int tmp;
void LEDInit(void)
{
  SysCtlPeripheralEnable(SYSCTL_PERIPH_GPIOF);
  GPIOPinTypeGPIOOutput(GPIO_PORTF_BASE, 
        GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3);
}

void LEDSet(int LED)
{
  switch(LED)
  {
    case LEDGREEN:
      GPIOPinWrite(GPIO_PORTF_BASE, GPIO_PIN_3, 1 << 3);
      break;
    case LEDBLUE:
      GPIOPinWrite(GPIO_PORTF_BASE, GPIO_PIN_2, 1 << 2);
      break;
    case LEDRED:
      GPIOPinWrite(GPIO_PORTF_BASE, GPIO_PIN_1, 1 << 1);
      break;
  }
}

void LEDClear(int LED)
{
  switch(LED)
  {
    case LEDGREEN:
      GPIOPinWrite(GPIO_PORTF_BASE, GPIO_PIN_3, 0);
      break;
    case LEDBLUE:
      GPIOPinWrite(GPIO_PORTF_BASE, GPIO_PIN_2, 0);
      break;
    case LEDRED:
      GPIOPinWrite(GPIO_PORTF_BASE, GPIO_PIN_1, 0);
      break;
  }
}

void LEDToggle(int LED)
{
  tmp = GPIOPinRead(GPIO_PORTF_BASE, LED);
  GPIOPinWrite(GPIO_PORTF_BASE, LED, ~tmp);
}

Hình 2-11: File mã nguồn led.c

File led.c chứa các định nghĩa hàm và các biến dùng trong module này. Biến tmp được khai báo với từ khóa static, nghĩa là biến này chỉ sử dụng nội bộ bên trong module led.c, các module bên ngoài không truy cập được biến này. Nếu một module khác cũng khai báo biến tmp với kiểu static, mặc dù hai biến này cùng tên nhau nhưng linker sẽ không báo lỗi, vì chúng được truy cập nội bộ bên trong module và được cấp phát các địa chỉ khác nhau.

6.5. Khai báo biến toàn cục cho chương trình

Các biến toàn cục là các biến có thể được truy xuất từ bất kỳ module nào trong chương trình. Các biến này chỉ được khai báo và cấp phát địa chỉ trong một module, nó được khai báo trong các module khác bằng từ khóa extern. Hình 2-12 mô tả biến a được khai báo trong module a.c và khai báo bằng từ khóa extern trong b.c

Khi biên dịch, biến Val được cấp phát 4 byte trong bộ nhớ trong module a.o, tuy nhiên khi biên dịch module b.o, trình biên dịch không cấp phát bộ nhớ cho Val, mà xem như Val là một biến nằm ở một module khác. Do đó khi linker liên kết hai module này lại, biến Val chỉ được cấp phát bộ nhớ 1 lần và quá trình liên kết sẽ không có lỗi. Bất kỳ module nào khác trong chương trình cũng có thể truy cập đến biến Val bằng cách khai báo nó là một biến extern. Để dễ dàng, ta có thể khai báo biến Val như là một biến extern trong file a.h, và các module khác sẽ gộp a.h vào bằng chỉ dẫn #include.

// a.c 
unsigned int Val;
void clearVal(void)
{
  a = 0;
}
// b.c
extern unsigned int Val;
int ValValue(void)
{
  return Val;
}

Hình 2-12: Khai báo extern

Hình 2-13 mô tả cách khai báo biến toàn cục Val trong file a.h. Bất cứ module nào có chỉ dẫn #include "a.h" đều có thể truy cập đến biến Val.

// a.h
#ifndef __A_H
#define __A_H
extern unsigned int Val;
void clearVal(void);
#endif
// a.c
#include "a.h"
unsgined int Val;
void clearVal(void)
{
  a = 0;
}
// b.c
#include "a.h"
#include "b.h"
int ValValue(void)
{
  return Val;
}

Hình 2-13: Khai báo biến toàn cục bằng từ khóa extern trong file header

6.6. Tùy biến khi biên dịch

Chỉ dẫn #if … #else … #endif có thể sử dụng để thay đổi cách chương trình thực hiện chỉ bằng cách thay đổi một định nghĩa trong file header trước khi biên dịch. Ví dụ sau mô tả cách dùng các chỉ dẫn này.

// debug.h
#ifndef __DEBUG_H
#define __DEBUG_H

#define DEBUG

#ifdef DEBUG
#define DBG(fmt, ...)   printf(fmt, ##___VA_ARGS__)
#else
#define DBG(fmt, args ...)
#endif

#endif
// main.c
#include "debug.h"
#include "stdio.h"
int main(void)
{
  initializeHardware();
  DBG("Hardware initialize\n");
  // Code goes here
}

Hình 2-14: Sử dụng chỉ dẫn #if … #else … #endif

Ở Hình 2-14, hàm DBG được định nghĩa tại thời điểm biên dịch tùy theo việc DEBUG đã được định nghĩa hay không. Nếu tham số DEBUG được định nghĩa, thì hàm DBG sẽ mang giá trị printf, còn ngược lại thì DBG sẽ không được định nghĩa, lệnh gọi đến DBG sẽ không được biên dịch.

Trong hàm main(), khi mới phát triển chương trình, ta gỡ rối chương trình bằng cách xuất thông báo ra serial port ở các thời điểm cần thiết bằng lệnh gọi DBG với DEBUG được định nghĩa bằng cách thêm dòng lệnh #define DEBUG vào file debug.h. Sau khi chương trình đã chạy đúng, ta bỏ các lệnh gỡ rối này đơn giản bằng cách xóa bỏ dòng này.

Chương 1: Cơ bản về lập trình C cho hệ thống nhúng

Chương 2: Cấu trúc chương trình

Icons made by Freepik from www.flaticon.com