Chuyển tới nội dung chính

BÀI 3. CÁC THÀNH PHẦN CƠ BẢN CỦA NGÔN NGỮ C

Mục tiêu​

Sau bài này, học viên:

  • Tách được một chương trình thành đơn vị từ vựng: từ khóa, định danh, hằng, toán tử, dấu phân cách.
  • Chọn được kiểu dữ liệu phù hợp và biết kích thước kiểu không phải chân lý vũ trụ mà là hợp đồng của chuẩn cộng với trình biên dịch.
  • Khai báo, khởi tạo và sử dụng biến; phân biệt được khai báo và phép gán.
  • Viết được biểu thức đúng độ ưu tiên, đúng kiểu, và đọc được chuyển kiểu ngầm định.
  • Dùng được printf / scanf đúng đặc tả chuyển đổi, kiểm tra giá trị trả về của scanf.
  • Hiểu con trỏ từ mô hình bộ nhớ: biến nằm ở một địa chỉ, & lấy địa chỉ, * đi vào địa chỉ đó để đọc hoặc ghi. Đọc xong phần này, người chưa từng thấy con trỏ vẫn giải thích được vì sao scanf cần dấu &.
  • Ở mức cao thủ: giải thích được thúc đẩy kiểu nguyên, tràn số có dấu là hành vi không xác định, số thực nhị phân không biểu diễn được mọi số thập phân, con trỏ NULL, con trỏ hằng, và vì sao so sánh == với float là bẫy.

Bài này chưa lấy rẽ nhánh và vòng lặp làm chủ đề. Một vài if chỉ để bảo vệ nhập liệu, đã gặp ở Bài 1.


1. Bộ từ vựng​

Chương trình C là dòng ký tự được tách thành token. Khoảng trắng ngăn token, không có nghĩa tính toán, trừ khi nằm trong chuỗi hoặc hằng ký tự.

1.1. Từ khóa​

Từ khóa là tên ngôn ngữ đã giữ. Không được dùng làm tên biến. Bộ cốt lõi cần thuộc trong khóa này:

auto, break, case, char, const, continue, default, do, double, else, enum, extern, float, for, goto, if, int, long, register, return, short, signed, sizeof, static, struct, switch, typedef, union, unsigned, void, volatile, while.

C99 thêm inline, restrict, _Bool, _Complex, _Imaginary. C11 thêm _Alignas, _Alignof, _Atomic, _Generic, _Noreturn, _Static_assert, _Thread_local.

Các từ khóa hay bị liệt kê rồi bỏ quên, nghĩa dùng trong khóa này:

Từ khóaNghĩa ngắnBài dùng sâu
autoBiến cục bộ thông thường. Không cần viết, vì đây là mặc định6
registerGợi ý giữ biến trong thanh ghi. Trình biên dịch được phép bỏ qua. Không lấy địa chỉ của biến register3
externTên này được định nghĩa ở file khác. Chỉ là khai báo, không tạo ô nhớ mới6
staticKéo thời gian sống ra cả chương trình, hoặc giới hạn tên trong một file6
volatileGiá trị có thể đổi ngoài chương trình, như thanh ghi phần cứng. Cấm trình biên dịch tối ưu bỏ lần đọc3
goto nhãn;Nhảy tới một nhãn trong cùng hàm. Phá cấu trúc tuần tự, rẽ nhánh, lặp. Khóa này không dùng goto để giải bài4
sizeofToán tử hỏi kích thước theo byte3
typedefĐặt tên mới cho một kiểu8

inline chỉ là gợi ý chép thân hàm vào chỗ gọi. Không đổi nghĩa chương trình.

main không phải từ khóa. Nó là tên hàm đặc biệt theo quy ước môi trường thực thi.

1.2. Định danh (tên)​

Tên gồm chữ, chữ số và _, không bắt đầu bằng chữ số. C phân biệt hoa thường: Tong và tong là hai tên. Độ dài nhận diện tối thiểu theo chuẩn là 31 ký tự cho tên nội bộ; đừng dựa vào việc tên dài 200 ký tự luôn khác nhau trên mọi trình biên dịch cổ.

Quy ước của khóa học: snake_case cho biến và hàm, tên nói dữ liệu chứ không nói kiểu (diem_trung_binh, không float1).

1.3. Chú thích​

/* Chú thích nhiều dòng, không lồng nhau */
// Chú thích đến hết dòng, từ C99

Chú thích không đến được trình biên dịch ngữ nghĩa. Đừng để chú thích nói một đằng, code một nẻo.


2. Kiểu dữ liệu cơ bản​

Kiểu cho biết: tập giá trị, cách mã hóa trong bộ nhớ, và phép toán hợp lệ.

KiểuÝ nghĩaKích thước tối thiểu theo chuẩn
charKý tự / số nguyên rất nhỏ8 bit
shortSố nguyên ngắn16 bit
intSố nguyên “tự nhiên” của máy16 bit, thực tế 32 bit trên máy học
longSố nguyên dài32 bit
long longSố nguyên rất dài (C99)64 bit
floatSố thực chính xác đơnthường 32 bit IEEE 754
doubleSố thực chính xác képthường 64 bit IEEE 754
long doubleRộng hơn hoặc bằng doublephụ thuộc trình biên dịch
_Bool / bool0 hoặc 1thường 1 byte

sizeof(char) luôn là 1. Các kiểu khác đo bằng đơn vị char. Trên LP64 (macOS, Linux 64-bit): sizeof(int) là 4, sizeof(long) là 8, sizeof(long long) là 8. Trên Windows 64-bit (LLP64): sizeof(long) vẫn là 4. Đừng viết thuật toán phụ thuộc “int luôn 4 byte, long luôn 8 byte”. Hãy dùng sizeof và các hằng trong <limits.h>, <float.h>.

char có thể signed hoặc unsigned tùy trình biên dịch. Khi lưu mã 0..255, hãy ghi unsigned char.

Số có dấu dùng biểu diễn bù hai trên gần như mọi máy hiện nay. Miền int 32 bit là từ -2147483648 đến 2147483647.

Tràn số có dấu (int vượt miền) là hành vi không xác định. Tràn số không dấu được định nghĩa: quay vòng theo modulo 2^số_bit.

Số thực float/double là số nhị phân có số mũ. Chúng không phải số thập phân vô hạn chính xác. 0.1 không có biểu diễn double hữu hạn đúng tuyệt đối. Sai số nhỏ là bình thường; so sánh bằng == là không bình thường.


3. Biến​

Biến là tên gắn với một vùng nhớ giữ giá trị có kiểu, và giá trị đó thay đổi được trong đời sống của biến.

int so_luong; /* khai báo, giá trị chưa xác định */
int so_luong = 0; /* định nghĩa kèm khởi tạo */
so_luong = 12; /* phép gán */

Đọc biến chưa khởi tạo là hành vi không xác định. Hãy khởi tạo tại chỗ khai báo nếu đã biết giá trị.

const int gioi_han = 100; không biến vùng nhớ thành hằng vật lý theo nghĩa mọi trình biên dịch, nhưng cấm gán qua tên đó. Muốn hằng thật sự trong biểu thức hằng, Bài này dùng thêm #define hoặc enum (enum học kỹ cùng cấu trúc ở Bài 8; ở đây chỉ cần biết hằng số nguyên).

Phân biệt:

#define TEN 10const int ten = 10;
Bản chấtThay văn bản trước khi biên dịchĐối tượng có kiểu
KiểuKhông cóCó
Gỡ lỗiTên có thể biến mấtTên còn trong debugger

4. Hằng​

Số nguyên​

10 thập phân, 010 bát phân (giá trị 8, bẫy kinh điển), 0x1A thập lục phân (26), 0b1010 nhị phân từ C23 (đừng dùng nếu dịch với C11). Hậu tố: u/U unsigned, l/L long, ll/LL long long. 10000000000 có thể không vừa int; viết 10000000000LL.

Số thực​

3.14, 2.5e-3 nghĩa là 0.0025. Hằng thực mặc định kiểu double. Hậu tố f cho float: 3.14f.

Ký tự và chuỗi​

'A' là hằng ký tự, kiểu int trong biểu thức. "A" là chuỗi hai byte: 'A' và '\0'. Nhầm hai thứ này là lỗi rất hay gặp từ Bài 7 trở đi; hãy khắc ngay.

Chuỗi rỗng "" vẫn có một byte '\0'. Escape thường dùng: \n xuống dòng, \t tab, \\ dấu \, \' nháy đơn, \" nháy kép, \0 ký tự mã 0.


5. Biểu thức, phép toán, độ ưu tiên​

Biểu thức là tổ hợp toán hạng và toán tử cho ra một giá trị. 3 + 7, 2 * y + 5, a >= 0 đều là biểu thức. Một biểu thức kèm ; có thể thành câu lệnh.

Nhóm toán tử​

NhómToán tửGhi chú
Số học+ - * / %% chỉ cho số nguyên. Chia hai int là chia nguyên
Quan hệ< > <= >=Kết quả 0 hoặc 1, kiểu int
Bằng== !== là gán, không phải so sánh
Logic! && ||Đoản mạch: && dừng nếu vế trái sai, || dừng nếu vế trái đúng
Bit~ & | ^ << >>Làm trên từng bit. Khác logic
Gán= += -= *= /= %=Gán là biểu thức, giá trị là giá trị vừa gán
Tăng giảm++ --Tiền tố đổi rồi cho giá trị mới; hậu tố cho giá trị cũ rồi đổi
Khácsizeof, (kieu), ?:, dấu phẩysizeof là toán tử, không phải hàm

Độ ưu tiên thực hành, từ cao xuống thấp, bản rút gọn đủ dùng:

  1. () và hậu tố ++ --
  2. Một ngôi: ++ -- ! ~ + - (ép kiểu) sizeof
  3. * / %
  4. + -
  5. << >>
  6. < <= > >=
  7. == !=
  8. & rồi ^ rồi |
  9. && rồi ||
  10. ?:
  11. Gán
  12. Dấu phẩy

Cùng mức thì hầu hết kết hợp trái sang phải; gán và ?: kết hợp phải sang trái.

Ví dụ kinh điển:

  • 3 * 5 + 4 = 19
  • 6 + 2 / 2 - 3 = 4
  • 4 > 10 có giá trị 0
  • 4 >= 4 có giá trị 1
  • 6 - 3 < 4 tương đương (6 - 3) < 4, giá trị 1

Hình dưới đây là cùng một biểu thức 6 + 2 / 2 - 3, làm theo tầng ưu tiên. Nhìn từ trên xuống, đừng nhìn từ trái sang phải một lượt.

6 + 2 / 2 - 3
│
▼ chia trước cộng và trừ
6 + 1 - 3
│
▼ cùng mức, trái sang phải
7 - 3
│
▼
4

Một hình thứ hai cho chỗ hay sai nhất, ép kiểu:

(double) a / b (double)(a / b)
a đổi sang thực a và b còn nguyên
rồi mới chia chia nguyên ra 3
ra 3.5 rồi mới đổi thành 3.0

Chia nguyên: 7 / 2 là 3 nếu cả hai toán hạng là int. 7.0 / 2 là 3.5. Dư: 7 % 2 là 1. Dấu của % khi số âm là chỗ dễ lệch giữa các chuẩn cũ; trong C99 trở đi, thương tiến về 0, nên (-7) / 2 là -3 và (-7) % 2 là -1.

Ép kiểu tường minh: (double) a / b ép a trước khi chia. (double)(a / b) chia nguyên trước rồi mới đổi sang thực — thường là sai ý định.

Chuyển đổi thường gặp. Khi int gặp double trong phép số học, int được đổi thành double. Trong biểu thức nguyên, các toán hạng hẹp hơn int (char, short) được thúc đẩy lên int trước. Đó là integer promotion.

++. Đừng viết a = a++ hay printf("%d %d", i, i++). Chuẩn không xếp thứ tự các hiệu ứng phụ này trong cùng một biểu thức không có điểm tuần tự rõ. Đó là hành vi không xác định. Mỗi câu lệnh một việc.


6. Nhập và xuất​

printf​

printf("định dạng", các giá trị);

Đặc tả hay dùng:

Ký hiệuKiểu đối số
%dint thập phân
%uunsigned int
%ld, %lldlong, long long
%fdouble khi printf (float được thúc đẩy thành double)
%edạng khoa học
%.2fhai chữ số sau dấu phẩy khi in
%cký tự
%schuỗi
%pcon trỏ, in địa chỉ; xem mục 7 của bài này
%%in một dấu %

Sai đặc tả so với kiểu đối số là hành vi không xác định: in rác hoặc tệ hơn. printf trả về số ký tự đã in, hoặc âm nếu lỗi.

scanf​

scanf("định dạng", &biến, ...);

scanf ghi vào biến, nên cần địa chỉ. Nó trả về số mục gán thành công, hoặc EOF khi hết input.

if (scanf("%d", &n) != 1) {
printf("Du lieu khong phai so nguyen.\n");
return 1;
}

Khoảng trắng trong chuỗi định dạng khớp mọi khoảng trắng trên input. %d tự bỏ khoảng trắng đầu. %c thì không. Dòng nhập còn ký tự thừa sẽ nằm lại trong bộ đệm stdin và làm lần scanf sau đọc phải chúng. Khi bài tập “nhập số rồi nhập ký tự” bị nhảy, hãy nghi bộ đệm trước khi nghi máy hỏng.

%f của scanf cần float *. %lf cần double *. Đây là chỗ printf và scanf không đối xứng: printf("%f", bien_double) vẫn đúng vì đối số float được thúc đẩy, còn scanf("%f", &bien_double) là sai.

Đọc và in một ký tự, không qua định dạng nhiều mục:

HàmViệc
getchar()Đọc một ký tự từ stdin, trả về mã int, hoặc EOF khi hết
putchar(c)In một ký tự
puts(s)In một chuỗi rồi tự xuống dòng
getsKhông dùng. Không có giới hạn độ dài, tràn bộ đệm

EOF là hằng báo hết luồng nhập, không phải một ký tự người dùng gõ được một cách thông thường. So getchar() với EOF bằng int, đừng nhét ngay vào char vì EOF có thể nằm ngoài miền của char có dấu.

Toán tử phẩy a, b tính a, bỏ giá trị đó, rồi tính b và lấy giá trị của b. Nó đứng cuối bảng ưu tiên. Đừng dùng trong bài giải để nhồi hai việc vào một biểu thức. Chỗ duy nhất hay gặp là ngăn bước của for cũ. Khóa này mỗi câu lệnh một việc.

Dấu & trong scanf("%d", &n) không phải nghi thức. Nó là địa chỉ của n. Phần tiếp theo giải thích địa chỉ đó là gì.


7. Con trỏ: bộ nhớ, địa chỉ, và hai toán tử & với *​

Con trỏ không phải mẹo, không phải chương sau. Con trỏ là cách C gọi địa chỉ của một ô nhớ. Nếu hiểu ô nhớ, bạn hiểu con trỏ. Nếu học thuộc cú pháp mà không hình dung ô nhớ, mọi bài sau sẽ rối.

7.1. Bộ nhớ là một dãy ô được đánh số​

Khi chương trình chạy, hệ điều hành cấp cho tiến trình một vùng nhớ. Hãy hình dung một dãy tủ, mỗi ngăn một byte. Mỗi ngăn có một số nhà gọi là địa chỉ. Địa chỉ là một số nguyên không âm, rất lớn, thường viết dạng thập lục phân như 0x7ffeee20.

Một biến int trên máy 4 byte chiếm bốn ngăn liền nhau. Tên biến là nhãn dán lên ngăn đầu. Giá trị của biến nằm trong các ngăn đó.

Địa chỉ (ví dụ) Ngăn nhớ Tên
0x1000 [ 0A ] ┐
0x1001 [ 00 ] │ bốn byte này là một int
0x1002 [ 00 ] │ tên của cụm này là tuoi
0x1003 [ 00 ] ┘ giá trị hiện tại: 10
0x1004 [ .. ] biến khác

Ba câu phải tách bạch, vì trộn chúng là nguồn mọi nhầm lẫn:

CâuNghĩaVí dụ
Giá trịThứ nằm trong biếntuoi đang là 10
Địa chỉSố nhà của ngăn đầu&tuoi là 0x1000
TênNhãn trong mã nguồnchữ tuoi

Tên chỉ tồn tại khi biên dịch và khi gỡ lỗi. Lúc chạy, CPU làm việc bằng địa chỉ.

7.2. Toán tử &: hỏi số nhà​

&tuoi đọc là “địa chỉ của tuoi”. Nó không lấy số tuổi. Nó lấy số nhà của ô đang giữ số tuổi.

int tuoi = 10;
printf("gia tri = %d\n", tuoi);
printf("dia chi = %p\n", (void *)&tuoi);

%p là đặc tả in địa chỉ. Ép (void *) là cách viết chuẩn khi in con trỏ bất kỳ. Kết quả mỗi lần chạy có thể khác nhau vì hệ điều hành đặt biến ở chỗ khác; điều đó bình thường. Bạn cần thấy có một số nhà, không cần học thuộc số đó.

& chỉ áp lên một lvalue: thứ có ô nhớ thật, như biến, phần tử mảng, thành viên struct. &10 vô nghĩa vì số 10 không có nhà. &(a + b) vô nghĩa vì tổng là giá trị tạm, không phải ô nhớ đặt tên (trừ một số ngoại lệ rất hẹp của chuẩn mà khóa này không dựa vào).

7.3. Biến con trỏ: một biến mà giá trị của nó là một địa chỉ​

Con trỏ là biến bình thường. Điều khác biệt duy nhất: giá trị nó giữ không phải tuổi, điểm, hay tiền, mà là địa chỉ của một biến khác.

int tuoi = 10;
int *p = &tuoi;

Đọc khai báo từ tên ra ngoài, đây là quy tắc cả đời:

  • p là tên.
  • *p nghĩa là “thứ mà p trỏ tới”.
  • int *p nghĩa là “*p có kiểu int”, nên p là con trỏ tới int.

Hình:

p (kiểu int *) tuoi (kiểu int)
┌──────────────┐ ┌──────────────┐
│ 0x1000 │ ──────────► │ 10 │
│ địa chỉ của │ │ giá trị │
│ tuoi │ └──────────────┘
└──────────────┘
nhà của p: 0x2000 nhà của tuoi: 0x1000

p và tuoi là hai ô nhớ khác nhau. p chứa số 0x1000. tuoi chứa số 10.

Dấu sao trong khai báo int *p và dấu sao trong biểu thức *p cùng một ký tự, hai vai trò:

Chỗ viếtVai trò
int *pKhai báo: p là con trỏ tới int
*p trong biểu thứcĐi theo địa chỉ và lấy ô ở đó

Viết int* p hay int *p đều được. Khóa này viết int *p để sao nằm sát tên, nhắc “sao gắn với tên”. Cảnh báo: int *p, q; nghĩa là p là con trỏ, q là int thường. Muốn hai con trỏ thì int *p, *q;. An toàn nhất: mỗi con trỏ một dòng khai báo.

7.4. Toán tử *: đi vào số nhà và mở tủ​

Nếu p đang giữ địa chỉ của tuoi thì *p chính là tuoi.

printf("%d\n", *p); /* in 10, đọc qua địa chỉ */
*p = 21; /* ghi 21 vào đúng ô của tuoi */
printf("%d\n", tuoi); /* in 21 */

*p = 21 không đổi số nhà nằm trong p. Nó đổi nội dung ngôi nhà mà p chỉ tới. Sau phép gán, p vẫn là 0x1000, tuoi thành 21.

Hai chiều cần thuộc đến mức không nghĩ:

& đi từ biến → ra địa chỉ (&tuoi cho ra một int *)
* đi từ địa chỉ → vào biến (*p cho ra một int)

Chúng nghịch nhau khi dùng đúng:

int tuoi = 10;
int *p = &tuoi;
/* *(&tuoi) chính là tuoi */
/* &(*p) chính là p, khi p đang trỏ tới một biến thật */

7.5. Vì sao scanf cần &​

scanf là một hàm. Hàm C nhận bản sao giá trị (Bài 6 sẽ nói kỹ hợp đồng này; ở đây chỉ cần một sự thật). Nếu gọi scanf("%d", n) thì hàm chỉ nhận bản sao của số đang nằm trong n. Nó có thể sửa bản sao, rồi bản sao biến mất. n của bạn không đổi.

scanf cần chỗ để ghi. Chỗ để ghi chính là địa chỉ. scanf("%d", &n) đưa số nhà của n vào hàm. Bên trong, hàm làm tương đương *địa_chỉ = số_vừa_đọc.

Bạn scanf
n = ??? ←── ghi vào đúng ô này, vì được đưa &n

printf("%d", n) chỉ cần đọc giá trị, nên đưa n, không đưa &n. Đưa nhầm &n cho %d là đưa một địa chỉ vào chỗ đang chờ một int: hành vi không xác định.

Quy tắc một dòng:

  • printf một số: đưa giá trị, không có &.
  • scanf một số: đưa địa chỉ, có &.

7.6. NULL: con trỏ cố ý không trỏ đi đâu​

NULL là một giá trị địa chỉ đặc biệt, nghĩa là “không trỏ tới đối tượng nào”. Khai báo trong <stddef.h>, và thường có sẵn qua <stdio.h>.

int *p = NULL;

*p khi p là NULL là hành vi không xác định. Trên nhiều máy, hệ điều hành dừng chương trình. Hãy kiểm tra trước khi đi theo con trỏ:

if (p != NULL) {
printf("%d\n", *p);
}

Con trỏ chưa khởi tạo còn nguy hiểm hơn NULL. Nó chứa một địa chỉ rác. Đi theo địa chỉ rác có thể ghi vào bộ nhớ của chính chương trình. Luôn khởi tạo con trỏ bằng &biến hoặc bằng NULL ngay khi khai báo.

7.7. Một con trỏ chỉ nên trỏ tới đúng kiểu​

int * trỏ tới int. double * trỏ tới double. Gán lẫn hai kiểu này mà không ép kiểu là lỗi. Lý do thực: *p phải biết đọc mấy byte và đọc theo cách nào. int 4 byte và double 8 byte không phải cùng một cách đọc.

int tuoi = 10;
int *p = &tuoi; /* đúng */
/* double *q = &tuoi; sai kiểu */

Ép (double *) &tuoi rồi đọc *q là hành vi không xác định. Đừng làm để “xem máy in ra gì”.

7.8. Con trỏ hằng và hằng con trỏ​

Đọc từ phải sang trái, hoặc nhớ vị trí const:

int tuoi = 10;
int diem = 9;
const int *p = &tuoi; /* được đổi p sang nhà khác, không được *p = ... */
int *const q = &tuoi; /* q kẹt tại nhà tuoi, nhưng *q = 11 được */
const int *const r = &tuoi; /* không đổi r, không đổi qua *r */

const int *p là hợp đồng “nhìn nhưng không ghi”. Hàm sau này nhận const int * để người gọi yên tâm dữ liệu không bị sửa. int *const q ít gặp hơn: chính biến con trỏ bị khóa.

7.9. Sai lầm kinh điển​

Việc viếtChuyện thật sự xảy ra
int *p; *p = 5;p chưa trỏ đi đâu, ghi vào địa chỉ rác
int *p = 5;gán số 5 như thể nó là địa chỉ, không phải gán giá trị 5 cho một int
scanf("%d", p) khi p chưa trỏ vào một int hợp lệscanf ghi vào nhà không tồn tại
printf("%d", p)in địa chỉ bằng đặc tả của số nguyên, sai kiểu
*p = *p + 1 khi p == NULLđi vào nhà số 0
Nhầm *p ở khai báo và *p ở biểu thứcquên mất khai báo chỉ nói kiểu

Muốn “một con trỏ tới số 5”, không có số 5 nào để trỏ tới trừ khi số 5 đang nằm trong một biến:

int x = 5;
int *p = &x;

7.10. Con trỏ và hàm, mức vừa đủ để dùng ngay​

Bạn được phép viết hàm nhận con trỏ ngay trong bài này. Ý nghĩa chỉ có một: người gọi đưa số nhà, hàm ghi hoặc đọc ngôi nhà đó.

void gan_hai_muoi(int *o_nho)
{
if (o_nho == NULL) {
return;
}
*o_nho = 20;
}

Gọi: gan_hai_muoi(&tuoi);. Sau lời gọi, tuoi là 20. Hàm không nhận bản sao của tuổi. Hàm nhận bản sao của địa chỉ, rồi dùng địa chỉ đó để sửa ô gốc. Bản sao địa chỉ vẫn chỉ cùng một nhà.

Chưa cần con trỏ tới con trỏ, chưa cần cấp phát động. Hai thứ đó xuất hiện khi mảng và danh sách cần vùng nhớ xin lúc chạy (Bài 7). Nền để hiểu chúng là sáu chữ: biến có nhà, con trỏ giữ số nhà, sao mở nhà.

7.11. Bài tập giấy trước khi gõ​

Lấy int a = 3; int b = 8; int *p = &a;. Điền bảng sau mỗi dòng, đừng chạy máy:

  1. Ban đầu: a, b, *p.
  2. Sau *p = *p + 1.
  3. Sau p = &b.
  4. Sau *p = a.
  5. Sau a = 100. Hỏi b có đổi không, và vì sao.

Đáp:

  1. a = 3, b = 8, *p = 3 vì p đang chỉ a.
  2. a = 4, b = 8, *p = 4.
  3. p chỉ b. a vẫn 4. *p là 8.
  4. *p = a ghi 4 vào b. b = 4. a vẫn 4.
  5. a = 100 không đụng ô của b. b vẫn 4. p vẫn chỉ b, nên *p vẫn 4.

Nếu bạn giải được năm dòng này trên giấy, bạn đã hiểu con trỏ ở mức dùng được. Phần ví dụ phía dưới chỉ là cùng mô hình đó viết thành chương trình.


8. Ví dụ​

Ví dụ 3.1 — Bảng sizeof trên chính máy bạn​

Phân tích. In kích thước và miền giá trị thay vì học thuộc một bảng trên giấy. Chương trình này là dụng cụ, sẽ dùng lại mỗi khi đổi máy.

Lưu đồ.

Mã nguồn.

#include <stdio.h>
#include <limits.h>
#include <float.h>

int main(void)
{
printf("char %zu\n", sizeof(char));
printf("short %zu\n", sizeof(short));
printf("int %zu\n", sizeof(int));
printf("long %zu\n", sizeof(long));
printf("long long %zu\n", sizeof(long long));
printf("float %zu\n", sizeof(float));
printf("double %zu\n", sizeof(double));
printf("INT_MIN %d\n", INT_MIN);
printf("INT_MAX %d\n", INT_MAX);
printf("UINT_MAX %u\n", UINT_MAX);
printf("FLT_DIG %d\n", FLT_DIG);
printf("DBL_DIG %d\n", DBL_DIG);
return 0;
}

Kết quả điển hình trên macOS 64-bit (số sizeof(long) có thể khác Windows):

char 1
short 2
int 4
long 8
long long 8
float 4
double 8
INT_MIN -2147483648
INT_MAX 2147483647
UINT_MAX 4294967295
FLT_DIG 6
DBL_DIG 15

Phân tích chi tiết. %zu là đặc tả cho kiểu size_t mà sizeof trả về. Dùng %d cho sizeof là sai kiểu trên máy 64-bit. FLT_DIG là số chữ số thập phân đáng tin của float, khoảng 6. Đừng in tiền tệ bằng float.


Ví dụ 3.2 — Máy tính bốn phép trên số nguyên​

Phân tích. Đọc hai int và một phép + - * / %. In kết quả. Với / và %, từ chối chia cho 0. Bài này cố ý dùng if tối thiểu vì chia cho 0 không phải là một giá trị biểu thức hợp lệ.

Lưu đồ.

Mã nguồn.

#include <stdio.h>

int main(void)
{
int a;
int b;
char phep;
int ket_qua;

printf("Nhap a phep b, vi du 7 / 2: ");
if (scanf("%d %c %d", &a, &phep, &b) != 3) {
printf("Sai dinh dang.\n");
return 1;
}

if ((phep == '/' || phep == '%') && b == 0) {
printf("Khong chia cho 0.\n");
return 1;
}

if (phep == '+') {
ket_qua = a + b;
} else if (phep == '-') {
ket_qua = a - b;
} else if (phep == '*') {
ket_qua = a * b;
} else if (phep == '/') {
ket_qua = a / b;
} else if (phep == '%') {
ket_qua = a % b;
} else {
printf("Phep khong ho tro.\n");
return 1;
}

printf("%d %c %d = %d\n", a, phep, b, ket_qua);
return 0;
}

Kết quả.

Nhap a phep b, vi du 7 / 2: 7 / 2
7 / 2 = 3
Nhap a phep b, vi du 7 / 2: 7 % 2
7 % 2 = 1

Phân tích chi tiết. 7 / 2 ra 3 vì cả hai là int. Nếu đề bài muốn 3.5, kiểu phải là double và đặc tả in là %f. Điều kiện chặn 0 đứng trước phép chia: đoản mạch của && không cần ở đây, nhưng thứ tự kiểm tra thì cần. Biểu thức a + b với a và b sát INT_MAX có thể tràn. Bản cao thủ sẽ tính trong long long rồi mới quyết định có in được bằng int hay không.


Ví dụ 3.3 — Ép kiểu đúng chỗ​

Phân tích. Tính trung bình cộng của hai điểm nguyên. Yêu cầu một chữ số lẻ không bị cắt. (a + b) / 2 sai ý vì chia nguyên. (double)(a + b) / 2 đúng. (double) a / 2 + b / 2 vẫn cắt b / 2.

Lưu đồ.

Mã nguồn.

#include <stdio.h>

int main(void)
{
int diem_1;
int diem_2;
double trung_binh;

printf("Hai diem: ");
if (scanf("%d %d", &diem_1, &diem_2) != 2) {
return 1;
}

trung_binh = (double)(diem_1 + diem_2) / 2.0;
printf("Trung binh = %.2f\n", trung_binh);
return 0;
}

Kết quả với 7 và 8.

Hai diem: 7 8
Trung binh = 7.50

Phân tích chi tiết. Nếu bỏ ép kiểu, 15 / 2 ra 7, in thành 7.00, sai đề. Ép kiểu là một toán tử có độ ưu tiên cao, nên dấu ngoặc quanh tổng là có chủ đích: muốn đổi cả tổng, không chỉ đổi số đầu.


Ví dụ 3.4 — Mặt nạ bit (mức cao thủ của toán tử)​

Phân tích. Một cảm biến đóng gói cờ trong một byte: bit 0 là “có khói”, bit 1 là “có chuyển động”, bit 2 là “pin yếu”. Cần bật, tắt và kiểm tra từng cờ mà không làm hỏng cờ khác. Đây là việc của phép bit, không phải phép logic &&.

Quy ước: 1u << k là mặt nạ có đúng bit k bật.

Lưu đồ.

Mã nguồn.

#include <stdio.h>

int main(void)
{
unsigned int co = 0u;
const unsigned int KHOI = 1u << 0;
const unsigned int CHUYEN_DONG = 1u << 1;
const unsigned int PIN_YEU = 1u << 2;

co = co | KHOI;
co = co | PIN_YEU;

if ((co & KHOI) != 0u) {
printf("Co khoi\n");
}
if ((co & CHUYEN_DONG) == 0u) {
printf("Khong co chuyen dong\n");
}

co = co & ~PIN_YEU;
printf("Trang thai = 0x%X\n", co);
return 0;
}

Kết quả.

Co khoi
Khong co chuyen dong
Trang thai = 0x1

Phân tích chi tiết.

  • co | KHOI bật bit mà không xóa bit khác.
  • co & KHOI tách một bit để kiểm tra. So với 0, đừng viết if (co & KHOI == 0) vì == ưu tiên hơn &, biểu thức thành co & (KHOI == 0) và sai nghĩa.
  • ~PIN_YEU đảo mọi bit của mặt nạ; AND với nó tắt đúng bit pin yếu. Kết quả còn bit 0, giá trị 1, in hexa là 0x1.
  • Dùng unsigned cho phép bit. Phép dịch bit trên số có dấu, hoặc dịch một lượng âm, hoặc dịch quá bề rộng, là vùng nguy hiểm của chuẩn.

Ví dụ 3.5 — Số thực không phải số thập phân​

Phân tích. Cộng 0.1 mười lần vào float và vào double, so với 1. Mục tiêu là thấy sai số, không phải “máy tính kém”.

Lưu đồ.

Mã nguồn.

#include <stdio.h>

int main(void)
{
float sf = 0.0f;
double sd = 0.0;
int i;

for (i = 0; i < 10; i = i + 1) {
sf = sf + 0.1f;
sd = sd + 0.1;
}

printf("float = %.10f\n", sf);
printf("double = %.20f\n", sd);
printf("float == 1? %d\n", sf == 1.0f);
printf("double == 1? %d\n", sd == 1.0);
return 0;
}

Kết quả điển hình.

float = 1.0000001192
double = 1.00000000000000022204
float == 1? 0
double == 1? 0

Phân tích chi tiết. 0 trong kết quả so sánh nghĩa là sai. Muốn biết hai số thực “gần bằng nhau”, hãy so hiệu trị tuyệt đối với một epsilon chọn theo bài toán, ví dụ 1e-6 cho tiền bậc trung học, hoặc một sai số tương đối khi số rất lớn. Epsilon không phải hằng vũ trụ. Vòng for ở đây chỉ là công cụ lặp 10 lần; cấu trúc vòng lặp được học đầy đủ ở Bài 5.


Ví dụ 3.6 — Nhìn giá trị và nhìn địa chỉ​

Phân tích. Tạo một int, một con trỏ trỏ tới nó, in giá trị, địa chỉ của biến, địa chỉ đang nằm trong con trỏ, và giá trị đọc qua con trỏ. Sau đó ghi qua con trỏ và in lại biến gốc. Đây là chương trình đối chiếu với hình ô nhớ ở mục 7.

Lưu đồ.

Mã nguồn.

#include <stdio.h>

int main(void)
{
int tuoi = 10;
int *p = &tuoi;

printf("gia tri tuoi = %d\n", tuoi);
printf("dia chi tuoi = %p\n", (void *)&tuoi);
printf("gia tri cua p = %p\n", (void *)p);
printf("gia tri tai *p = %d\n", *p);

*p = 21;

printf("sau khi ghi qua p, tuoi = %d\n", tuoi);
printf("sau khi ghi qua p, *p = %d\n", *p);
return 0;
}

Kết quả (địa chỉ trên máy bạn sẽ khác; hai dòng địa chỉ phải trùng nhau).

gia tri tuoi = 10
dia chi tuoi = 0x7ff7b2c4a1c
gia chi cua p = 0x7ff7b2c4a1c
gia tri tai *p = 10
sau khi ghi qua p, tuoi = 21
sau khi ghi qua p, *p = 21

Phân tích chi tiết. Hai dòng %p in cùng một số vì p được gán bằng &tuoi. *p = 21 không đổi p. Nó đổi ô mà p chỉ. Vì ô đó chính là tuoi, tên tuoi và biểu thức *p là hai cách gọi cùng một ngăn. Nếu hai địa chỉ in ra khác nhau, phép gán p = &tuoi đã không còn trong chương trình.


Ví dụ 3.7 — Đổi chỗ con trỏ chỉ, không đổi nhầm ô​

Phân tích. Có a = 3, b = 8, p ban đầu chỉ a. Thực hiện đúng năm bước của bài tập giấy 7.11 và in sau mỗi bước. Mục tiêu là thấy sự khác nhau giữa p = &b (đổi số nhà trong con trỏ) và *p = ... (đổi nội dung ngôi nhà).

Lưu đồ.

Mã nguồn.

#include <stdio.h>

int main(void)
{
int a = 3;
int b = 8;
int *p = &a;

printf("1) a=%d b=%d *p=%d\n", a, b, *p);
*p = *p + 1;
printf("2) a=%d b=%d *p=%d\n", a, b, *p);
p = &b;
printf("3) a=%d b=%d *p=%d\n", a, b, *p);
*p = a;
printf("4) a=%d b=%d *p=%d\n", a, b, *p);
a = 100;
printf("5) a=%d b=%d *p=%d\n", a, b, *p);
return 0;
}

Kết quả.

1) a=3 b=8 *p=3
2) a=4 b=8 *p=4
3) a=4 b=8 *p=8
4) a=4 b=4 *p=4
5) a=100 b=4 *p=4

Phân tích chi tiết. Bước 2 sửa a vì p còn chỉ a. Bước 3 không sửa số nào; chỉ đổi hướng của p, nên *p đột ngột thành 8. Bước 4 ghi giá trị của a (đang là 4) vào ô b. Bước 5 sửa a bằng tên của a. b không liên quan, p vẫn chỉ b, nên *p vẫn 4. Đây là bài phải đối chiếu từng dòng với hình hai ngôi nhà trước khi tin máy.


Ví dụ 3.8 — Hàm ghi vào ô nhớ người gọi đưa địa chỉ​

Phân tích. Viết dat_gia_tri nhận địa chỉ một int và một số cần ghi. Nếu con trỏ là NULL, không ghi. main gọi với &diem. In điểm trước và sau để thấy ô gốc đổi.

Lưu đồ.

Mã nguồn.

#include <stdio.h>

void dat_gia_tri(int *o, int gia_tri)
{
if (o == NULL) {
return;
}
*o = gia_tri;
}

int main(void)
{
int diem = 0;
printf("Truoc: %d\n", diem);
dat_gia_tri(&diem, 9);
printf("Sau: %d\n", diem);
dat_gia_tri(NULL, 9);
printf("Sau loi goi NULL, diem van la: %d\n", diem);
return 0;
}

Kết quả.

Truoc: 0
Sau: 9
Sau loi goi NULL, diem van la: 9

Phân tích chi tiết. Lần gọi thứ nhất đưa số nhà của diem. Hàm giữ bản sao của số nhà trong o, rồi *o = 9 ghi vào nhà gốc. Lần gọi NULL đi vào nhánh return ngay, không có *o, nên chương trình không chết và diem không bị đụng. Bỏ nhánh NULL rồi vẫn gọi dat_gia_tri(NULL, 9) là bài tự làm để quan sát chương trình bị dừng. Đừng coi việc “máy không chết” là bằng chứng an toàn: đó là may.


Ví dụ 3.9 — const chặn ghi qua con trỏ​

Phân tích. Một điểm đã chốt không được sửa qua con trỏ chỉ đọc. Chương trình dưới đây biên dịch được. Bản gỡ chú thích ở dòng ghi sẽ bị trình biên dịch từ chối. Lưu đồ mô tả việc đọc hợp lệ.

Lưu đồ.

Mã nguồn.

#include <stdio.h>

int main(void)
{
int diem = 8;
const int *p = &diem;

printf("Doc qua con tro chi doc: %d\n", *p);
diem = 10;
printf("Sua bang ten bien van duoc: %d\n", *p);
/* *p = 11; lenh nay bi trinh bien dich tu choi */
return 0;
}

Kết quả.

Doc qua con tro chi doc: 8
Sua bang ten bien van duoc: 10

Phân tích chi tiết. const trên int * khóa cửa ghi qua con trỏ p, không khóa ngôi nhà. Tên diem vẫn ghi được, và lần đọc sau qua p thấy 10. Nếu cần khóa cả ngôi nhà, hãy khai báo const int diem = 8; thì diem = 10 cũng bị từ chối. Hai lớp const trả lời hai câu hỏi khác nhau: “con trỏ này có được ghi không?” và “biến này có được ghi không?”.


9. Bài tập có gợi ý​

Bài 3.G1 — Đọc biểu thức trên giấy​

a = 8, b = 3, c = 5, kiểu int. Tính: a + b * c, a % b, (a + b) % c, a / b, b > c, a != b && c > 0.

Gợi ý. Nhân chia trước cộng trừ. && sau quan hệ. Viết từng bước rút gọn, rồi mới viết chương trình in đối chứng.

Bài 3.G2 — Ba số thực​

x = 8.8, y = 3.5, z = 5.2. Tính 5 * y + 6 * (x - z), (z / y) + x, 2.5 * x / z - (y + 6).

Gợi ý. Dùng double. In %.10f để thấy phần thập phân, đừng kết luận sai chỉ vì làm tròn hai chữ số.

Bài 3.G3 — Đổi giây sang giờ, phút, giây​

Nhập số giây từ 0 đến 86399.

Gợi ý. gio = t / 3600, phut = (t % 3600) / 60, giay = t % 60. Từ chối ngoài đoạn. Đây là chia nguyên có chủ đích.

Bài 3.G4 — Hình cầu​

Nhập bán kính không âm. Diện tích 4 * pi * r * r, thể tích (4.0 / 3.0) * pi * r * r * r.

Gợi ý. Viết 4.0 / 3.0, không viết 4 / 3. Nếu r là double, đừng vô tình chia nguyên hệ số.

Bài 3.G5 — Sai đặc tả scanf​

Viết một chương trình cố ý dùng scanf("%f", &x) với x kiểu double. Quan sát cảnh báo -Wall. Sửa thành %lf.

Gợi ý. Kết quả “tình cờ đúng” trên một máy không xóa lỗi. Cảnh báo mới là dữ liệu cần chép vào vở.

Bài 3.G6 — Bật tắt bit​

Một biến unsigned biểu diễn quyền: bit 0 đọc, bit 1 ghi, bit 2 xóa. Nhập một số là mặt nạ hiện tại và một số quyền cần bật thêm. In kết quả dạng thập phân và hexa.

Gợi ý. Chỉ dùng |. Đừng dùng + để “cộng quyền”: nếu bit đã bật, phép cộng làm lan sang bit bên cạnh.

Bài 3.G7 — Vẽ nhà trước khi chạy​

Khai báo int x = 4; int y = 7; int *p = &x; int *q = &y;. Vẽ hai ngôi nhà và hai con trỏ. Viết kết quả của *p, *q sau từng dòng: *p = *q;, rồi p = q;, rồi *p = 1;.

Gợi ý. Dòng *p = *q chép nội dung, không chép địa chỉ. Dòng p = q làm hai con trỏ chỉ cùng một nhà. Dòng cuối sửa nhà đó, nên cả x lẫn y không cùng đổi: chỉ nhà mà cả hai đang chỉ mới đổi. Hãy chỉ ra nhà nào.

Bài 3.G8 — scanf tự viết bằng lời​

Giải thích bằng một hình ô nhớ vì sao scanf("%d", n) sai và scanf("%d", &n) đúng khi n là int.

Gợi ý. Hàm nhận bản sao. Bản sao của giá trị không phải chỗ để ghi. Bản sao của địa chỉ vẫn chỉ đúng nhà.

Bài 3.G9 — Phát hiện khai báo sai​

Chỉ ra lỗi của từng dòng, không cần chạy: int *p = 10;, int *q; *q = 10;, int *a, b;, printf("%d", p); khi p là int *.

Gợi ý. Dòng 1 gán địa chỉ bằng số 10. Dòng 2 đi theo địa chỉ rác. Dòng 3 chỉ a là con trỏ. Dòng 4 sai đặc tả in.

Bài 3.G10 — Hoán đổi qua địa chỉ, trên giấy​

Viết thân hàm void hoan_doi(int *a, int *b) và bảng giá trị với x = 1, y = 2 trước và sau hoan_doi(&x, &y).

Gợi ý. Cần biến tạm int t = *a. Đừng viết int *t = a rồi chỉ đổi các con trỏ cục bộ: nơi gọi không thấy.


10. Bài tập tự làm​

Cơ bản​

  1. Nhập hai số nguyên, in tổng, hiệu, tích, thương nguyên và phần dư. Chặn chia cho 0.
  2. Nhập bán kính hình tròn, in chu vi và diện tích với 4 chữ số thập phân.
  3. Nhập một ký tự, in ký tự đó, mã của nó dưới dạng số nguyên, và ký tự đứng liền sau trong bảng mã.
  4. In kích thước mọi kiểu cơ bản trên máy bạn, kèm một câu kết luận máy bạn thuộc nhóm long 4 byte hay 8 byte.

Vững​

  1. Nhập ngày, tháng, năm ở dạng ba số nguyên và in lại theo dd/mm/yyyy với số 0 đứng trước nếu cần (%02d). Chưa cần kiểm tra lịch đúng (Bài 4 sẽ làm). Vẫn phải kiểm tra scanf đọc đủ 3 số.
  2. Nhập một số thực là số tiền, in số tiền kèm VND với 0 chữ số thập phân và với 2 chữ số. Giải thích sự khác nhau giữa làm tròn khi in và giá trị trong biến.
  3. Tính giá trị biểu thức 2 * ((i / 4) + (6 * (j - 3)) % (i + j - 4)) với i, j nhập vào. Nếu mẫu của % bằng 0, từ chối. Làm trên giấy một ca trước khi chạy.
  4. Viết chương trình chứng minh 010 khác 10. In cả hai bằng %d.

Nâng cao​

  1. Nhập unsigned int và in biểu diễn nhị phân 32 bit của nó bằng cách dịch bit, không dùng thư viện chuỗi. Thứ tự in từ bit cao đến bit thấp.
  2. Cộng 0.1f mười lần, một trăm lần, một nghìn lần. Lập bảng sai số so với giá trị toán học. Đề xuất một cách cộng “tiền” bằng số nguyên (đơn vị đồng hoặc xu) để hết sai số biểu diễn.
  3. Đọc long long là số mili-giây từ mốc nào đó, đổi sang ngày, giờ, phút, giây, mili-giây còn lại. Dùng kiểu đủ rộng cho các phép chia. Giải thích vì sao int không an toàn nếu đầu vào có thể tới hàng tỷ.

Siêu khó, có ứng dụng​

  1. Bộ giải mã gói tin 16 bit. Một khung cảm biến: bit 15..12 là loại cảm biến (0..15), bit 11..1 là giá trị đo (0..2047), bit 0 là cờ lỗi. Nhập khung dạng số nguyên không dấu. Tách ba trường bằng dịch và mặt nạ. In loại, giá trị, và “lỗi” hoặc “tốt”. Lập 5 ca, trong đó có ca cờ lỗi bật nhưng giá trị đo khác 0. Viết rõ các hằng mặt nạ. Không dùng chuỗi nhị phân làm đầu vào.
  2. Máy tính biểu thức có kiểm soát tràn. Nhập hai long long và một phép + - *. Tính kết quả nếu vừa long long; nếu không, in TRAN và trả mã thoát 1. Với phép nhân, đừng nhân trực tiếp rồi “nhìn kết quả” vì tràn có dấu là hành vi không xác định. Hãy kiểm tra trước bằng phép chia hoặc bằng __int128 nếu trình biên dịch của bạn hỗ trợ, và ghi rõ giả định đó trong chú thích.
  3. Đặc tả làm tròn điểm học phần. Điểm thành phần là số thực 0..10. Điểm tổng kết = 0.1 * chuyen_can + 0.3 * giua_ky + 0.6 * cuoi_ky. In với hai chữ số. Viết thêm hàm so sánh “bằng nhau trong sai số 0.0005” để xác định một điểm có đang nằm sát ngưỡng làm tròn hay không (ví dụ 4.995). Nêu ca mà in hai chữ số ra 5.00 nhưng biến vẫn nhỏ hơn 5. Bài này là ứng dụng thật của việc tách giá trị và cách hiển thị.

Con trỏ — từ dễ đến khó​

  1. Khai báo int n = 42; int *p = &n;. In n, *p, &n, p. Chỉ ra hai dòng nào phải trùng.
  2. Viết chương trình hỏi người dùng một số nguyên bằng scanf("%d", &n), rồi in gấp đôi số đó. Trong chú thích, ghi một câu: dấu & đưa địa chỉ nào cho scanf.
  3. Viết void nhan_doi(int *o) tăng gấp đôi ô được trỏ. Gọi với &x khi x = 6. In 12. Thêm lời gọi với NULL và chứng minh chương trình không ghi gì.
  4. Có ba biến int a, b, c. Con trỏ p lần lượt chỉ a, rồi b, rồi c. Mỗi lần chỉ, ghi vào ô đó đúng số thứ tự 1, 2, 3. In a b c phải ra 1 2 3. Không được gán a = 1 bằng tên a.
  5. Viết hàm int doc(const int *o) trả về *o nếu o khác NULL, trả về -1 nếu o là NULL. Giải thích vì sao trong hàm này không viết được *o = 0.
  6. Nhập hai số. Dùng một con trỏ int *lon để chỉ vào biến lớn hơn (nếu bằng nhau thì chỉ vào biến thứ nhất). In giá trị qua *lon, rồi tăng *lon thêm 1 và in lại cả hai biến để thấy chỉ một biến đổi.
  7. Tìm và sửa: int *p; scanf("%d", p);. Viết lại đúng. Giải thích lỗi cũ bằng hình ô nhớ, không bằng câu “thiếu dấu sao”.
  8. Viết void sap_hai_o(int *x, int *y) để sau lời gọi, ô x chỉ không lớn hơn ô y. Dùng hoán đổi ba bước nếu cần. Kiểm thử (9, 2), (2, 9), (4, 4), và (NULL, &a).

Siêu khó​

  1. Ba con trỏ, một ngôi nhà. int x = 5; int *p = &x; int *q = p; int *r = &x;. Tăng *q lên 1, rồi gán *r = *p + *q. Tính x trên giấy rồi chạy đối chiếu. Viết một đoạn 8 dòng giải thích vì sao ba con trỏ không tạo ra ba bản sao của x.
  2. Hằng đúng lớp. Viết bốn chương trình ngắn, mỗi chương trình một dòng bị cấm, chứng minh trình biên dịch từ chối: ghi qua const int *, đổi int *const, ghi vào const int, và gán int * từ const int * không có ép kiểu. Chép thông báo lỗi vào bài nộp.
  3. Mô phỏng thanh ghi thiết bị. Một unsigned int trang_thai là ô nhớ của cảm biến. Viết void bat_bit(unsigned int *reg, unsigned int mask) và void tat_bit(...), int dang_bat(const unsigned int *reg, unsigned int mask). Cấm hàm nào nhận NULL. Kiểm thử bật bit 0 và bit 3, tắt bit 0, đọc lại. Đây là đúng kiểu API điều khiển phần cứng: hàm không nhận giá trị thanh ghi, hàm nhận địa chỉ thanh ghi.

Dự án thật​

  1. Sổ điểm thô trong một lần chạy. Chương trình giữ năm biến double là năm cột điểm của một sinh viên. Viết hàm int nhap_diem(double *o) dùng scanf vào đúng địa chỉ o, từ chối điểm ngoài 0..10 bằng cách trả 0, thành công trả 1. main chỉ gọi nhap_diem(&chuyen_can) và các cột còn lại, không viết scanf trong main. In điểm trung bình. Nếu một cột nhập sai quá 3 lần, dừng với mã thoát 1. Bài này ép bạn dùng con trỏ như công cụ thiết kế, không như bài tập cú pháp.
  2. Nhật ký sửa giá. Một cửa hàng có biến long long gia. Viết void dat_gia(long long *gia, long long moi, long long *gia_cu): lưu giá hiện tại vào *gia_cu rồi ghi giá mới, từ chối giá âm và con trỏ NULL bằng cách trả int thành công hoặc thất bại. main chạy 5 lần đổi giá và in từng cặp giá cũ, giá mới. Kiểu tiền là đồng nguyên, không dùng float.

11. Câu hỏi tự kiểm​

  1. char, 'A', "A" khác nhau thế nào?
  2. Vì sao 7 / 2 và 7.0 / 2 khác nhau?
  3. scanf("%f", &x) với x là double sai ở đâu?
  4. && và & khác nhau chỗ nào?
  5. Tràn unsigned và tràn int có dấu, cái nào được chuẩn định nghĩa?
  6. sizeof(int) trên máy bạn là bao nhiêu, và chuẩn đảm bảo tối thiểu bao nhiêu?
  7. &x và *p mỗi toán tử đi theo chiều nào?
  8. int *p = NULL; *p = 1; sai ở đâu?
  9. p = &b khác *p = b thế nào?
  10. Vì sao const int *p vẫn thấy giá trị mới khi sửa biến bằng tên của biến?

12. Tóm tắt và cầu nối​

C nhìn chương trình như token, kiểu, tên, hằng, biểu thức và câu lệnh. Kiểu quyết định miền giá trị và phép toán. Biểu thức tuân độ ưu tiên; chia nguyên và ép kiểu là hai chỗ sai nhiều nhất. printf/scanf đòi đúng đặc tả và, với scanf, đúng địa chỉ cùng giá trị trả về.

Con trỏ là biến giữ số nhà của một ô nhớ. & hỏi số nhà. * mở ô tại số nhà đó. NULL là cố ý không có nhà. Con trỏ chưa khởi tạo là nhà rác. scanf cần & vì hàm phải ghi vào ô của người gọi. Nắm hình hai ngôi nhà trước khi học mảng: tên mảng sau này chính là địa chỉ phần tử đầu.

Bài 4 dùng các biểu thức quan hệ và logic vừa học để lái chương trình theo những nhánh loại trừ nhau.

Bộ 25 bài tập​

  1. Tính trên giấy 8 + 3 * 5 và (8 + 3) * 5. Viết chương trình in cả hai. Gợi ý: Nhân trước cộng. In cả hai kết quả trên hai dòng.
  2. Chứng minh 010 khác 10 bằng một chương trình in cả hai theo %d. Gợi ý: Số bắt đầu bằng 0 là bát phân.
  3. Nhập một ký tự, in ký tự, mã số, và ký tự liền sau. Gợi ý: printf với %c và %d. Ký tự sau là c + 1.
  4. In sizeof của char, int, long, double trên máy bạn. Gợi ý: Dùng %zu cho sizeof.
  5. Nhập hai số nguyên, in tổng, hiệu, tích. Chặn chưa cần chia. Gợi ý: Ba printf. Chưa cần phép chia.
  6. Khai báo int n = 42; int *p = &n;. In n, *p, và hai địa chỉ phải trùng. Gợi ý: %p in địa chỉ. Ép (void *) khi in con trỏ.
  7. Vẽ hai ngôi nhà cho a = 3, b = 8, p = &a. Điền *p sau *p = *p + 1. Gợi ý: Dòng này sửa a, chưa đụng b.
  8. Chỉ ra lỗi của dòng int *q; *q = 10; bằng một câu về số nhà. Gợi ý: q chưa giữ số nhà nào. Cấm *q.
  9. Nhập hai số nguyên, in thương nguyên và phần dư. Chặn chia cho 0. Gợi ý: Nếu b == 0 thì return 1 trước khi chia hoặc lấy dư.
  10. Đổi số giây từ 0 đến 86399 sang giờ, phút, giây. Gợi ý: gio = t/3600, phut = (t%3600)/60, giay = t%60.
  11. Nhập bán kính không âm, in diện tích và thể tích hình cầu. Hệ số viết 4.0/3.0. Gợi ý: Dùng double và 4.0/3.0. r âm thì dừng.
  12. Trung bình hai điểm nguyên phải ra số thực. Ép kiểu đúng chỗ. Ca 7 và 8 ra 7.50. Gợi ý: (double)(a + b) / 2.0. Đừng chia nguyên trước.
  13. Viết chương trình cộng 0.1f mười lần và in kết quả so với 1. Gợi ý: In %.10f. Đừng kết luận bằng ==.
  14. scanf một double bằng đúng đặc tả %lf. Cố ý dùng %f một lần để xem cảnh báo, rồi sửa. Gợi ý: double đi với %lf trong scanf.
  15. Hàm void nhan_doi(int *o) nhân đôi ô được trỏ. Có nhánh NULL. Gợi ý: Nếu o khác NULL thì *o = *o * 2.
  16. Ba biến a, b, c. Con trỏ lần lượt ghi 1, 2, 3 vào từng ô. Cấm gán bằng tên biến. Gợi ý: p = &a rồi *p = 1, rồi làm tương tự với b và c.
  17. In 32 bit của một unsigned int từ bit cao xuống bit thấp. Gợi ý: Mặt nạ 1u << k, dịch từ bit 31 xuống bit 0.
  18. Bật bit 0 và bit 3 của một thanh ghi, tắt bit 0, in hexa. Gợi ý: Bật bằng |, tắt bằng & ~mask.
  19. const int *p đọc được điểm, dòng *p = 1 phải bị trình biên dịch từ chối. Chép lỗi. Gợi ý: const đứng trước int * thì cấm ghi qua con trỏ.
  20. Hai long long và một phép + hoặc *. Nếu kết quả không vừa kiểu, in TRAN mà không gây tràn có dấu. Gợi ý: Kiểm tra trước khi cộng hoặc nhân. Không nhìn kết quả sau khi tràn.
  21. Tách một khung 16 bit: bit 15..12 loại cảm biến, bit 11..1 giá trị đo, bit 0 cờ lỗi. Năm ca. Gợi ý: Tách bằng dịch và &. Đừng đọc nhầm bit 0 thành giá trị đo.
  22. Sổ điểm một lần chạy. Năm biến double. Hàm nhap_diem(double *o) từ chối điểm ngoài 0..10. main không gọi scanf trực tiếp. Quá ba lần sai thì thoát mã 1. Gợi ý: Mỗi cột điểm một lời gọi nhap_diem(&bien).
  23. Đổi giá. dat_gia(long long *gia, long long moi, long long *gia_cu) lưu giá cũ rồi ghi giá mới, từ chối âm và NULL. Năm lần đổi, in từng cặp. Gợi ý: Lưu *gia vào *gia_cu rồi mới gán giá mới.
  24. Tiền không dùng số thực. Cộng tiền bằng đơn vị đồng long long. In số tiền với hậu tố VND. Một đoạn giải thích vì sao không dùng float. Gợi ý: Nhân số lượng với đơn giá bằng long long.
  25. Bảng sai số. Cộng 0.1 một trăm lần bằng float và bằng double. Bảng sai số so với 10. Kết luận một câu cho việc cộng tiền. Gợi ý: Sai số = giá trị máy trừ 10. float lệch nhiều hơn double.