Cơ bản về Lập Trình C cho Hệ thống Nhúng (Phần 2)

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

7. Con trỏ và mảng

Con trỏ (pointer)

Các biến được lưu trữ trong bộ nhớ và truy cập thông qua đại chỉ trong bộ nhớ. Con trỏ là một biến chứa địa chỉ của các biến, hằng số, hàm hay đối tượng dữ liệu. Một biến con trỏ được khai báo như sau:

char *p;  // p is a pointer to a character
int *fp;  // fp is a pointer to a integer

Biến con trỏ được cấp phát bộ nhớ đủ để lưu giá trị địa chỉ. Tùy thuộc vào họ vi xử lý mà biến con trỏ có kích thước khác nhau, tùy thuộc vào độ rộng bus địa chỉ. Đối với ARM, biến con trỏ gồm 4 byte vì ARM có độ rộng bus địa chỉa là 32 bit.

Sau khi con trỏ được khai báo, ta phải “trỏ” con trỏ vào một biến bằng cách gán địa chỉ của biến đó vào con trỏ:

char *p;  // p is a pointer to a character
char a, b;  // a and b are characters
p = &a;  // p is now pointing to a

Để lấy giá trị tại địa chỉ con trỏ đang trỏ đến, toán tử * được sử dụng.

b = *p  // Lệnh tương đương với b = a, vì
        // lúc này p đang chỉ đến địa chỉ 
        // của a, và *p chính là a
b = *p  // Lệnh được diễn dịch là b đươc 
        // gán giá trị tại địa chỉ đang được 
        // chỉ đến bởi p
p = &a  // Lệnh này được diễn dịch là p trỏ 
        // đến đại chỉ của a

Khi diễn dịch các lệnh với con trỏ như vậy, ta sẽ tránh được các lỗi thường gặp như sau:

b = p; // Lệnh này gán giá trị của p vào b, 
       // đây là địa chỉ của biến a, không phải 
       // giá trị ta đang mong muốn
p = a; // Lệnh này gán giá trị của a vào p,
       // không phải địa chỉ của a, do đó p không
       // trỏ đến a như ta mong muốn

Phép toán trên con trỏ

Con trỏ luôn chỉ đến địa chỉ đầu của đối tượng dữ liệu trong bộ nhớ có kiểu của con trỏ. Cộng 1 vào con trỏ sẽ làm cho con trỏ chỉ đến địa chỉ đầu của đối tượng tiếp theo. Trừ 1 vào con trỏ làm con trỏ chỉ đến địa chỉ đầu của đối tượng trước đó.

int *p;
int *k;
p = 0x2000;
*p = 0x67542310;

Sau các lệnh trên, ta có các ô nhớ có giá trị như hình vẽ, đồng thời con trỏ p chỉ đến ô nhớ 0x2000

Nếu ta gán:

k = p + 1;

Con trỏ p và k là hai con trỏ kiểu int, có kích thước dữ liệu là 4 byte. Do đó k sẽ chỉ đến đối tượng tiếp theo ở địa chỉ 0x2004.

Con trỏ luôn luôn trỏ đến địa chỉ đầu của đối tượng dữ liệu bất kể kiểu của nó. Vì vậy, ta dễ dàng ép kiểu con trỏ.

Phép gán char a = *(char*)p sẽ làm cho a = 0x10. Bởi vì lúc này kiểu của con trỏ chuyển sang kiểu char. p đang chỉ đến địa chỉ 0x2000, nghĩa là *(char*)p = 0x10.

Toán tử *, ++, —

Các phép toán tăng (++) và giảm (–) cũng thường được sử dụng cho con trỏ. Lưu ý là toán tử * và ++, — có cùng độ ưu tiên, nên chúng được xử lý từ trái qua phải.

char c;
char *p;
c = *p++;  // gán giá trị con trỏ đang chỉ đến 
           // vào c, sau đó tăng p lên 1
c = *++p;  // tăng p lên 1, rồi gán giá trị con
		   // trỏ đang chỉ đến vào c
c = ++*p;  // cộng 1 vào giá trị đang trỏ đến
		   // bởi p, rồi gán cho c
c = (*p)++; // gán giá trị con trỏ đang chỉ đến 
			// vào c, rồi cộng 1 vào giá trị 
			// đang trỏ đến bởi p

Trong hệ thống nhúng, phần lần công việc là tương tác với các ngoại vị. Với các ngoại vi on-chip như port xuất nhập GPIO, UART, ta điều khiển bằng cách truy cập các thanh ghi ở các địa chỉ xác định. Với ngoại vi off-chip các ngoại vi này cũng thường được thiết kế theo dạng ánh xạ bộ nhớ (memory-mapped), và CPU tương tác với các ngoại này như thể đó làm một ô nhớ có địa chỉ xác định. Khi đó, con trỏ là phương pháp tốt nhất để truy cập và điều khiển các ngoại vi này.

Ví dụ: Để điều khiển PORTF của vi xử lý LM4F120, ta phải truy cập thanh ghi GPIO_PORTF_DATA_R ở địa chỉ 0x400253FC. Cách thông thường nhất là định nghĩa 1 macro như sau

#define PORTF_DATA_R    *(volatile unsigned long*)0x400253FC

Macro này có thể diễn dịch là: giá trị kiểu long tại địa chỉ 0x400253FC

PORTF_DATA_R = 0x01;  // set bit PF.0 lên 1, các bit còn lại về 0

Ta lưu ý từ khóa volatile ở đây. Vì GPIO là ngoại vi dạng memory-mapped nên ta phải khai báo kiểu volatile cho macro này.

Mảng (array)

Một mảng là một dãy liên tiếp các phần tử có cùng kiểu, được khai báo như sau:

type ArrayName[size];

Ví dụ:

int digits[10];  // khai báo mảng tên digits chứa 10 số kiểu int
char str[20];  // khai bảo mảng tên str chứa 10 số kiểu char

Một mảng cũng có thể được khởi tạo giá trị đầu khi khai báo

char digits[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

Kiểu mảng thông dụng nhất là mảng các ký tự, hay còn được gọi là chuỗi (string). Một biến chuỗi được khai báo như là mộ mảng các ký tự, còn một hằng chuỗi được mô tả bằng cách đặt chuỗi trong dấu nháy kép như sau

char str[12];            // biến chuỗi
printf("Hello World!");  // dãy ký tự Hello World!
						 // được đặt trong bộ nhớ chương trình
const cstr[] = "Constant String";  // hằng số cuỗi được đặt
                         // trong bộ nhớ chương trình

Trình biên dịch C sẽ đặt giá trị 0 vào cuối mỗi chuỗi để đánh dấu vị trí kết thúc chuỗi, gọi là chuỗi kết 0 (null-terminated). Vì vậy, khi khai báo chuỗi ta phải đảm bảo kích thước biến chuỗi đủ để chứa nội dung chuỗi và giá trị 0 kết thúc chuỗi. Ở ví dụ trên, chuỗi cstr sẽ chiếm 16 byte trong bộ nhớ chương trình, bao gồm giá trị kết thúc. Các phần tử trong mảng được truy cập thông qua tên mảng và chỉ số (index). Chỉ số có tầm từ 0 cho đến size – 1.

Ví dụ:

int anarray[4];
anarray[0] = 1;
anarray[1] = 2;
anarray[2] = 3;
anarray[3] = 4;

Khi không có chỉ số, tên của mảng được xem như là địa chỉ của phần tử đầu tiên trong mảng. Ví dụ ta có biến sau:

char str[20];
char *p;

Phép gán

p = str;

Tương đương với

p = &str[0];  // p chỉ đến str[0]

Mảng đa chiều

C hỗ trợ mảng đa chiều, là mảng có lớn hơn một chiều. Có thể xem mảng đa chiều như là mảng của các mảng một chiều. Một mảng hai chiều được khai báo như sau

int two_d[5][10];

Các phần tử của mảng này được sắp xếp trong bộ nhớ như sau:

two_d[0][0], two_d[0][1], two_d[0][2],... two_d[0][9], 
two_d[1][0], two_d[1][1], two_d[1][2],... two_d[0][9], 
two_d[2][0], two_d[2][1], two_d[2][2],... two_d[2][9], 
two_d[3][0], two_d[3][1], two_d[3][2],... two_d[3][9], 
two_d[4][0], two_d[4][1], two_d[4][2],... two_d[4][9],

Khi khởi tạo giá trị cho mảng đa chiều, cách sắp xếp các giá trị khởi tạo như sau

int matrix[3][4] = { 0, 1, 2, 3,
                     4, 5, 6, 7,
                     8, 9, 10, 11,};

Khi khởi tạo giá trị cho mảng đa chiều, cách sắp xếp các giá trị khởi tạo như sau

int keys[3][4] = { '1', '2', '3',
                   '4', '5', '6',
                   '7', '8', '9',
                   '*', '0', '#'};

Bảng tra (look-up table)

Một ứng dụng quan trọng của mảng là bảng tra (look-up table). Bảng tra có thể làm giảm thời gian để CPU thực hiện các phép tính, đặc biệt đối với các vi điều khiển 8 bit.

Một ví dụ là viết một hàm tính bình phương của một số từ 0-9 cho vi điều khiển Z80.

const char lookupTable[10] = {0, 1, 4, 9, 16, 25, 36, 49, 64, 81};
char square(char input)
{
    return lookupTable[input];
}

Z80 là vi điều khiển 8 bit không hỗ trợ lệnh nhân, vì vậy các lệnh nhân sẽ phải thực thi bằng nhiều phép tính cộng và dịch bit, làm tiêu tốn thời gian xử lý. Với bảng tra, các kết quả được tính toán sẵn và đặt trong bộ nhớ chương trình. Tùy theo giá trị vào, hàm chỉ việc tra bảng để có được kết quả.

8. Struct và Union

Struct

Một struct được dung để tạo ra một đối tượng dữ liệu từ một hoặc nhiều biến. Các biến bên trong struct gọi là các trường và có thể có các kiểu khác nhau. Một struct thường có mô tả như sau

struct structure_tag_name {
    type member_1;
    type member_2;
    type member_x;
}

Sau khi được mô tả, tên struct structure_tag_name được dùng để khai báo các biến trong chương trình

struct structure_tag_name var1, var2, var3[5];

Các biến trong struct có có bất kỳ kiểu nào, bao gồm cả kiểu struct, con trỏ hàm, con trở struct. Lưu ý rằng sau khi mô tả struct, bộ nhớ vẫn chưa được cấp phát. Bộ nhớ chỉ được cấp phát sau khi biến được khai báo. Biến có thể được khai báo ngay khi mô tả struct.

struct structure_tag_name {
    type member_1;
    type member_2;
    type member_x;
} struct_var_name

Các trường bên trong struct có thể được truy cập qua tên biến và dấu “.” .Ví dụ:

struct_var_name.member1 = 1;

Khi khai báo, struct cũng có thể được khởi động với giá trị

struct DATE {
    int month;
    int day;
    int year;
};
struct DATE date_of_birth = {2, 10, 1981};

Ta có thể dùng struct bên trong struct. Ví dụ:

struct LOCATION {
    int x;  // this is the location
    int y;  // coordinates x and y
};
struct PART {
    char part_name[20];
    long int sku;
    struct LOCATION bin;
} widget;

Ta có thể truy cập vào vị trí của widget như sau

x_location = widget.bin.x;
y_location = widget.bin.y;

Một hàm cũng có thể nhận struct làm tham số hoặc giá trị trả về. Ví dụ hàm sau:

struct PART new_location(int x, int y)
{
    struct PART temp;
    temp.part_name = ""; // initialized the name to NULL
    temp.sku = 0;    // zero the sku number
    temp.bin.x = x;  // set the location to the passed 
    temp.bin.y = y;  // x and y
    return temp;
}

Sẽ trả về một struct kiểu PART với location được khởi tạo là (x, y). Hàm này có thể được gọi như sau:

widget = new_location(10, 10);

Mảng các struct

Struct cũng có thể là kiểu của một mảng

struct PART {
    char part_name[20];  // a string for the part name
    long int sku;        // a SKU number for the part
    struct LOCATION bin; // its location in the warehouse
} widget[100];

Khai báo trên tạo ra một mảng có 100 phần tử kiểu PART. Ta có thể dễ dàng truy cập từng phần tử như sau

x_location = widget[12].bin.x;

Mảng các struct cũng có thể được khởi tạo giá trị đầu như sau

struct DATE {
    int month;
    int day;
    int year;
};
struct DATE birth_dates[3] = {2, 10, 1981,
                              8, 8, 1974,
                              7, 11, 1997};

Con trỏ chỉ đến struct

Một con trỏ chỉ đến struct được khai báo như sau

struct struct_tag_name *struct_var_name;

Lưu ý rằng sau khi khai báo, con trỏ struct_var_name phải được gán giá trị để nó “trỏ” vào một biến có kiểu struct_tag_name. Ví dụ:

struct LOCATION {
    int x;  // this is the location 
    int y;  // coordinates x and y
};
struct PART {
    char part_name[20];  // a string for the part name
    long int sku;        // a SKU number for the part
    struct LOCATION bin; // its location in the warehouse
};
struct PART widget, *this_widget;

this_widget = &widget;  // assign the pointer to the 
                        // of address a structure

Sau khi gán địa chỉ của biến widget vào con trỏ this_widget, mọi thao tác trên this_widget sẽ ảnh hương đến biến widget. Để truy cập các trường của một struct qua con trỏ, dấu “->” được sử dụng:

this_widget->sku = 1234;

Ta cũng có thể truy cập các trường của struct một cách trực tiếp:

(*this_widget).sku = 1234;

Struct có thể chứa con trỏ chỉ đến một struct khác hoặc chỉ đến một struct cùng kiểu với nó. Lưu ý rằng một struct không thể chứa một biến có cùng kiểu với nó.

struct LIST_ITEM {
    char *string;  // a text string
    int position;  // its position in a list
    struct LIST_ITEM *next_item;
} item, item2;
item.next_item = &item2;

Kiểu struct chứa con trỏ chỉ đến struct có cùng kiểu này thường được dùng trong các ứng dụng xử lý dữ liệu như là danh sách liên kết, sắp xếp nhanh.

Union

Union được khai báo và truy cập giống như struct. Một union sẽ được khai báo như sau:

union union_tag_name {
    type member_1;
    type member_2;
    type member_x;
}

Sự khác nhau giữa struct và union là các trường bên trong union được cấp phát chung một vùng nhớ có kích thước bằng kích thước lớn nhất của các trường. Ví dụ:

union SOME_TYPES {
    char character;
    shor shortinteger;
    int integer;
} my_space;

Sau khi khai báo, biến my được cấp phát 4 byte, là kích thước của trường integer. Nếu một giá trị được gán vào trường integer, hai trường còn lại cũng sẽ thay đổi giá trị. Ví dụ:

my_space.integer = 0x12345678;

Nếu kiểu dữ liệu là little endian, trường character sẽ mang giá trị 0x78, trường shortinteger sẽ mang giá trị 0x5678. Union thường được sử dụng để phân tách khối dữ liệu lớn thành các thành phần nhỏ. Ví dụ:

union
{
    int integer;
    struct
    {
        char firstByte;
        char secondByte;
        char thirdByte;
        char fourthByte;
    } Bytes;
} HILO;

Ta có thể dùng HILO để tách từng byte trong 1 số 4 byte. Nếu ta gán:

HILO.integer = 0x12345678;

Thì HILO.Bytes.firstByte sẽ mang giá trị 0x78, HILO.Bytes.secondByte sẽ mang giá trị 0x56, …

Bitfield

Khi làm việc với hệ thống nhúng, ta thường làm việc với các thanh ghi, mỗi bit trong thanh ghi sẽ có một chức năng khác nhau. Ví dụ như thanh ghi PORTA của ARM có 32 bit, mỗi bit điều khiển một chân port. Giả sử PORTA chỉ có 8 chân, điều khiển bởi 8 bit thấp của thanh ghi PORTA. Để truy cập đến từng bit PORTA ở địa chỉ 0xFFFF000, ta định nghĩa kiểu bits như sau

typedef struct 
{
    int bit_0: 1;
    int bit_1: 1;
    int bit_2: 1;
    int bit_3: 1;
    int bit_4: 1;
    int bit_5: 1;
    int bit_6: 1;
    int bit_7: 1;
} bits;

Thanh ghi PORTA sẽ được khai báo như sau:

#define PORTA   (*(bits* 0xFFFF0000))

Sau đó, từng bit có thể được truy cập độc lập:

PORTA.bit_0 = 1;

9. Từ khóa sizeof

sizeof dùng để trả về kích thước của một kiểu hay một biến tính theo byte

sizeof( type_name )  // type_name could be keyword
                     // int, char, long, ...
sizeof( object )     // object could be a variable,
                     // array, structure, or union
                     // variable

Giả sử ta có các khai báo sau:

int value, x;
long nt array[2][3];
struct record
{
    char name[24];
    int id_number;
} students[100];

x = sizeof(int);  // this would set x = 4
                  // since an int is 4 bytes
x = sizeof(students); // x = 100 Elements * 24 char 
                  // + sizeof(int)
                  // x = 100 * (24 + 4) = 2800

10. Định nghĩa kiểu (typedef)

Ta có thể định nghĩa một kiểu bằng từ khóa typedef. Ví dụ:

typedef unsigned char byte;
typedef unsigned int word;

Sau đó, từ khóa byte hay word có thể dùng để khai báo các biến theo kiểu unsigned char hay unsigned int:

byte var1;
word var2;

typedef hay được dùng với struct hoặc union:

typedef struct
{
    char name[20];
    char age;
    int home_room_number;
} student;

student Bob;
student Maria;

11. Từ khóa volatile

Từ khóa volatile được dùng để khai báo một biến mà giá trị của nó có thể được thay đổi mà không phụ thuộc vào các đoạn mã trong module mà trình biên dịch đang xử lý. Các biến kiểu volatile được dùng cho các ngoại vi kiểu ánh xạ bộ nhớ, các biến được thay đổi giá trị trong trình phục vụ ngắt, các biến toàn cục trong một ứng dụng đa tác vụ.

Ngoại vi kiểu ánh xạ bộ nhớ

Giả sử chân PA5 của LM4F120 được nối vào 1 nút nhấn, khi nút nhấn này được nhấn sẽ làm cho chân này bằng 0. Để đọc trạng thái nút nhấn, ta định nghĩa nút nhấn là giá trị của ô nhớ 0x40004080 vì ô nhớ này chứa giá trị của chân port PA5.

#define SW   (*((unsigned long *)0x40004080))

Sau đó ta chờ chân port này bằng 0:

while(SW);

Chương trình như vậy sẽ chạy đúng nếu ta không cho phép chứ năng tối ưu hóa của trình biên dịch. Nếu ta sử dụng chức năng tối ưu hóa, mã assembly của lệnh trên sẽ như sau:

    LDR    r0, 0x40004080
    LDR    r0, [r0, #0x00]
LOOP:
    CMP    r0, #0x00
    BNE    LOOP

Ta thấy CPU chỉ đọc giá trị ở ô nhớ 0x40004080 đúng một lần bằng lệnh LDR r0, [r0, #0x00], sau đó chỉ thực thi các lệnh so sánh với 0 trong vòng lặp phía dưới. Nếu lệnh đọc ban đầu trả về giá trị 0, chương trình sẽ bị dừng ở đây cho dù sau đó nút nhấn có được nhấn.

Sở dĩ như vậy vì trình biên dịch nhận thấy rằng không có lệnh nào trong chương trình làm cho ô nhớ 0x40004080 thay đổi giá trị, nên chỉ cần một lệnh đọc là đủ. Trong khi đó, giá trị ô nhớ này có thể được thay đổi bất kỳ lúc nào do tác động bên ngoài. Để báo cho trình biên dịch biết tính chất của khai báo SW, ta phải thêm vào từ khóa volatile như sau:

#define SW   (*((volatile unsigned long *)0x40004080))

Khi đó mã assembly của lệnh while(SW) sẽ như sau:

    LDR    r0, =0x40004080
LOOP:
    LDR    r0, [r0, #0x00]
    CMP    r0, #0x00
    BNE    LOOP

Ta thấy CPU sẽ đọc giá trị của PA5 trước khi thực hiện lệnh so sánh, đảm bảo rằng chương trình sẽ đáp ứng khi PA5 bằng 0.

Biến toàn cục sử dụng trong ngắt hay chương trình đa tác vụ

Giả sử ta có chương trình sau:

int flag = 0;

void someISR
{
    flag = 1;
}

int main(void)
{
    while(!flag);
}

Hoàn toàn tương tự như phần vừa rồi, khi chức năng tối ưu hóa của trình biên dịch được cho phép, biến flag chỉ được đọc đúng một lần khi CPU thực hiện lệnh while(!flag). Khi đó, cho dù ngắt xảy ra và biến flag thay đổi giá trị, chương trình vẫn không thể thoát khỏi vòng lặp while(!flag). Để giải quyết vấn đề này, ta cũng phải khai báo biến flag theo kiểu volatile.

volatile int flag = 0;

(Phần 3)

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