Mục lục khóa họcMở

Chương 6 · Bài 30Học thử miễn phí

Linux GPIO Legacy: Điều khiển LED bằng ioremap

Mục chấm điểmLàm thử

Làm thử miễn phí ngay trên web, không cần đăng nhập, không tính điểm.

Bài này điều khiển một LED trên Raspberry Pi 4 bằng cách ghi thẳng vào thanh ghi của bộ điều khiển GPIO, không qua gpiolib. Muốn truy cập thanh ghi từ kernel module, ta phải đổi địa chỉ vật lý của thanh ghi thành địa chỉ ảo bằng ioremap(). Bài giải thích ngắn ba loại địa chỉ (bus, physical, virtual), các thanh ghi GPIO cần dùng, viết module bật LED khi insmod và tắt khi rmmod, rồi ghép vào driver /dev/led của bài Character Device Driver để thay đoạn LED mô phỏng. Mọi ví dụ chạy trên board ảo Raspberry Pi 4 (QEMU) của khóa học.

Cảnh báo: không insmod hai module này trên PC. Chúng ghi vào địa chỉ vật lý 0xFE200000, là vùng chỉ có ý nghĩa trên SoC BCM2711 của Raspberry Pi 4. Trên máy khác, địa chỉ đó có thể là RAM hoặc thiết bị khác và máy có thể treo.

Mục tiêu bài học

Sau bài học này, bạn sẽ có thể:

  • Phân biệt địa chỉ bus, địa chỉ vật lý và địa chỉ ảo, và tính địa chỉ thanh ghi GPIO.
  • Mô tả các thanh ghi GPFSEL, GPSET, GPCLR, GPLEV và cách chọn một chân làm output.
  • Viết module dùng ioremap(), readl(), writel() để bật LED khi insmod và tắt khi rmmod.
  • Thay LED mô phỏng của driver /dev/led bằng thanh ghi GPIO thật.

1. Địa chỉ thanh ghi: bus, physical, virtual

1.1. Ba loại địa chỉ

Cùng một thanh ghi GPIO có ba địa chỉ, tùy ai nhìn vào. Ta không cần hiểu sâu cơ chế, chỉ cần biết mỗi loại dùng ở đâu và đổi qua lại thế nào.

LoạiVí dụ (GPIO block)Ai dùngGhi chú
Bus address0x7E200000Datasheet BCM2711Địa chỉ trong không gian của bus ngoại vi của SoC
Physical address0xFE200000CPU đặt lên bus bộ nhớĐịa chỉ dùng trong device tree và để gọi ioremap()
Virtual addressdo kernel chọn khi chạyCode C trong kernelCon trỏ void __iomem * mà ioremap() trả về
gpio_legacy_ioremap-hinh-01

Code kernel không truy cập thẳng địa chỉ vật lý, vì CPU luôn đi qua MMU và chỉ hiểu địa chỉ ảo. ioremap() nhờ kernel tạo một ánh xạ từ một dải địa chỉ ảo tới dải địa chỉ vật lý của thanh ghi, rồi trả về địa chỉ ảo đầu dải. Sau đó dùng readl() và writel() để đọc ghi, không dùng con trỏ thường, vì hai hàm này đảm bảo truy cập đúng 32 bit và đúng thứ tự.

Đổi từ bus sang physical: datasheet in địa chỉ bus bắt đầu bằng 0x7E, còn CPU thấy ngoại vi bắt đầu từ 0xFE000000. Trong device tree của kernel Raspberry Pi 4 (bcm2711.dtsi) có dòng ranges = <0x7e000000 0x0 0xfe000000 0x01800000>, nghĩa là dải bus bắt đầu ở 0x7E000000 ứng với dải vật lý bắt đầu ở 0xFE000000, dài 24 MB. Do đó:

physical = bus - 0x7E000000 + 0xFE000000

Các dòng SoC khác của Raspberry Pi có địa chỉ vật lý khác (Pi 3 dùng 0x3F000000), nên khi đổi board phải xem lại ranges trong device tree.

1.2. Tính địa chỉ một thanh ghi

Datasheet cho địa chỉ bus của từng thanh ghi. GPIO block bắt đầu ở bus 0x7E200000, tức vật lý 0xFE000000 + 0x200000 = 0xFE200000. Địa chỉ một thanh ghi là địa chỉ đầu block cộng offset của nó:

Thanh ghiOffsetBus address (datasheet)Physical address
GPFSEL10x040x7E2000040xFE200004
GPSET00x1C0x7E20001C0xFE20001C
GPCLR00x280x7E2000280xFE200028
GPLEV00x340x7E2000340xFE200034

Trong code, ta chỉ gọi ioremap() một lần cho cả block rồi cộng offset vào địa chỉ ảo: gpio_base + GPSET0. Không cần tính địa chỉ ảo của từng thanh ghi.

2. Thanh ghi GPIO sử dụng

2.1. Các thanh ghi của GPIO block

GPIO block của BCM2711 có các thanh ghi 32 bit, trong device tree dài 0xB4 byte nên ioremap() 0x100 byte là đủ. Bài chỉ dùng bốn nhóm, tô màu ở hình minh họa. Mỗi nhóm có hai thanh ghi: số 0 điều khiển chân 0 đến 31, số 1 điều khiển chân 32 đến 57.

gpio_legacy_ioremap-hinh-02
NhómThanh ghi dùng trong bàiTruy cậpTác dụng
Function selectGPFSEL0 đến GPFSEL5Đọc ghiChọn chức năng của từng chân: input, output hay chức năng thay thế. Mỗi chân 3 bit
Output setGPSET0Chỉ ghiGhi 1 vào bit n thì chân n lên mức cao. Ghi 0 không có tác dụng
Output clearGPCLR0Chỉ ghiGhi 1 vào bit n thì chân n xuống mức thấp. Ghi 0 không có tác dụng
Pin levelGPLEV0Chỉ đọcBit n là mức điện áp hiện tại của chân n

Tách GPSET và GPCLR thành hai thanh ghi giúp bật tắt một chân mà không cần đọc, sửa, ghi lại (read-modify-write), và không ảnh hưởng các chân khác. Để bật chân 17 chỉ cần writel(1U << 17, gpio_base + GPSET0).

2.2. Chọn chân là output

Mỗi chân có một trường 3 bit trong GPFSEL. Một thanh ghi GPFSEL chứa 10 trường (30 bit), hai bit cao nhất không dùng. Chân n nằm ở thanh ghi GPFSEL(n / 10), tại bit (n % 10) * 3. Giá trị 001 là output, 000 là input.

Với LED ở GPIO17: 17 / 10 = 1 nên là GPFSEL1 (offset 0x04), 17 % 10 * 3 = 21 nên trường FSEL17 chiếm bit 23 đến 21 như hình minh họa.

gpio_legacy_ioremap-hinh-03

Vì GPFSEL1 còn chứa cấu hình của chín chân khác, phải đọc, xóa đúng 3 bit rồi ghi giá trị mới, để không đổi chân khác:

fsel = readl(gpio_base + GPFSEL1);
fsel &= ~(7U << ((LED_PIN % 10) * 3));
fsel |= 1U << ((LED_PIN % 10) * 3);
writel(fsel, gpio_base + GPFSEL1);

3. Bước 1: LED sáng khi insmod, tắt khi rmmod

3.1. Nối LED

Bài dùng một LED ngoài nối vào GPIO17. Nối GPIO17 (chân 11 của header) qua điện trở 330 ohm tới anode của LED, cathode về GND (chân 6), như hình minh họa. Trên board ảo, khung Mô phỏng có sẵn một mạch gồm điện trở và LED với hai đầu IN và GND (không cần breadboard): bấm chân 11 của Pi rồi bấm đầu IN để kéo dây, làm tương tự với chân 6 và đầu GND. Muốn thử chân GPIO khác thì nối vào chân đó và đổi LED_PIN trong code.

gpio_legacy_ioremap-hinh-04

Điện trở giới hạn dòng qua LED. Không nối LED trực tiếp vào chân GPIO.

3.2. Trình tự insmod và rmmod

Xem lại: cách nạp và gỡ module bằng insmod, rmmod, và hàm module_init(), module_exit() ở bài Linux Kernel Module: Hello World, mục 4.

Hình minh họa là toàn bộ việc module làm.

gpio_legacy_ioremap-hinh-05
  1. insmod nạp module và gọi led_init().
  2. ioremap(GPIO_PHYS, GPIO_SIZE) ánh xạ 0x100 byte của GPIO block, trả về địa chỉ ảo gpio_base.
  3. Đọc, sửa, ghi GPFSEL1 để chân 17 là output.
  4. Ghi 1 << 17 vào GPSET0: chân 17 lên cao, LED sáng.
  5. rmmod gọi led_exit(): ghi 1 << 17 vào GPCLR0, chân 17 xuống thấp, LED tắt.
  6. iounmap(gpio_base) giải phóng ánh xạ.

3.2.1. Ví dụ 1

Ví dụ này gồm một module: led_init() làm các bước 2 đến 4, led_exit() làm bước 5 và 6. Dòng pr_info in cả địa chỉ vật lý và địa chỉ ảo, và in mức chân đọc lại từ GPLEV0 (level=1 khi chân cao, level=0 khi thấp). Đọc lại mức chân cho phép kiểm tra ngay cả khi không nhìn thấy LED.

File gpio_led_init.c:

// SPDX-License-Identifier: GPL-2.0
/*
 * gpio_led_init: drive the LED on GPIO17 by writing the BCM2711 GPIO registers
 * directly.
 */
#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt

#include <linux/init.h>
#include <linux/io.h>
#include <linux/module.h>

/* GPIO block of BCM2711 (Raspberry Pi 4). */
#define GPIO_PHYS	0xFE200000UL
#define GPIO_SIZE	0x100

/* Register offsets. */
#define GPFSEL1		0x04
#define GPSET0		0x1C
#define GPCLR0		0x28
#define GPLEV0		0x34

/* The LED is wired to GPIO17. */
#define LED_PIN		17

/* Virtual address of the mapped GPIO registers. */
static void __iomem *gpio_base;

static unsigned int led_get_level(void)
{
	return (readl(gpio_base + GPLEV0) >> LED_PIN) & 1;
}

static int __init led_init(void)
{
	u32 fsel;

	/* Step 1: map the registers, return -ENOMEM if it fails. */
	gpio_base = ioremap(GPIO_PHYS, GPIO_SIZE);
	if (!gpio_base)
		return -ENOMEM;

	/* Step 2: read GPFSEL1 and clear the 3 bits of pin 17. */
	fsel = readl(gpio_base + GPFSEL1);
	fsel &= ~(7U << ((LED_PIN % 10) * 3));

	/* Set the 3 bits to 001 (output) and write the register back. */
	fsel |= 1U << ((LED_PIN % 10) * 3);
	writel(fsel, gpio_base + GPFSEL1);

	/* Step 3: turn the LED on through GPSET0. */
	writel(1U << LED_PIN, gpio_base + GPSET0);

	/* Step 4: log the addresses and the level read back from GPLEV0. */
	pr_info("LED on, phys=0x%lx virt=%p level=%u\n",
		GPIO_PHYS, gpio_base, led_get_level());

	return 0;
}

static void __exit led_exit(void)
{
	/* Step 5: turn the LED off through GPCLR0. */
	writel(1U << LED_PIN, gpio_base + GPCLR0);

	/* Log the level, then unmap the registers. */
	pr_info("LED off, level=%u\n", led_get_level());
	iounmap(gpio_base);
}

module_init(led_init);
module_exit(led_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("DEVLINUX Academy");
MODULE_DESCRIPTION("LED on GPIO17 through direct register access");

Trên board ảo, code được build thành .ko và nằm sẵn trong thư mục /home/devlinux. Nạp module, xem log, đọc mức chân bằng lệnh pin của board ảo (in GPIO17=<mức>), rồi gỡ module:

$ ls
Module.symvers     gpio_led_init.ko   modules.order
gpio_led_init.c    gpio_led_init.mod
$ insmod gpio_led_init.ko
$ dmesg | tail -n 2
[    3.744221] gpio_led_init: loading out-of-tree module taints kernel.
[    3.753691] gpio_led_init: LED on, phys=0xfe200000 virt=(____ptrval____) level=1
$ pin 17
GPIO17=1
$ rmmod gpio_led_init
$ dmesg | tail -n 1
[    3.836612] gpio_led_init: LED off, level=0
$ pin 17
GPIO17=0
  • phys=0xfe200000 là địa chỉ vật lý của GPIO trên Pi 4. virt= là địa chỉ ảo do ioremap() trả về, nhưng kernel che địa chỉ bằng (____ptrval____) khi in bằng %p. Muốn thấy địa chỉ thật chỉ để học thì dùng %px, không dùng trong driver thật.
  • level=1 đọc lại từ GPLEV0 ngay sau khi ghi GPSET0 cho thấy chân 17 đã lên cao, pin 17 cho cùng kết quả và LED trên bo ảo sáng.
  • Sau rmmod, level=0 và GPIO17=0: ghi GPCLR0 đã kéo chân xuống thấp, LED tắt.
  • Dòng taints kernel chỉ báo module không nằm trong cây kernel. Thời gian ở đầu dòng log thay đổi mỗi lần chạy.

3.3. Lưu ý

Vấn đềGiải thích
Không dùng request_mem_region()Driver GPIO của hệ điều hành (pinctrl-bcm2835) đã chiếm vùng này nên gọi sẽ báo lỗi. ioremap() không kiểm tra chiếm dụng nên vẫn chạy được
Hai nơi cùng ghi một thanh ghiCách "legacy" này bỏ qua gpiolib, nên không được dùng chân đang do driver khác quản lý. Dùng chân 17 khi không bật chức năng thay thế nào khác trên chân đó
Chân vẫn là output sau rmmodModule chỉ kéo chân xuống thấp, không trả GPFSEL về input. Muốn trả lại, ghi 000 vào trường FSEL17 trong led_exit()
Cách chuẩn trong kernelDriver thật dùng gpiod_set_value() của gpiolib hoặc device tree, không ghi thanh ghi trực tiếp. Bài này để hiểu thanh ghi bên dưới

4. Bước 2: Ghép với character device driver

4.1. Thay đoạn LED mô phỏng

Xem lại: driver /dev/led với file_operations, copy_from_user() và hàm led_write() ở bài Linux Character Device Driver, mục 6.1.1 (Ví dụ 5). Biến led_on ở đó chỉ mô phỏng LED.

Driver /dev/led đã có sẵn đường đi từ echo on > /dev/led tới hàm led_write(). Phần phải thay là chỗ đặt và đọc trạng thái LED. Bảng dưới là toàn bộ khác biệt so với led_rw.c:

Việcled_rw.c (mô phỏng)gpio_led_cdev.c (thật)
Trạng thái LEDBiến led_onMức chân 17 trong thanh ghi
Bật LEDled_on = truewritel(1U << LED_PIN, gpio_base + GPSET0)
Tắt LEDled_on = falsewritel(1U << LED_PIN, gpio_base + GPCLR0)
Đọc trạng thái (read)Trả về led_onĐọc GPLEV0, lấy bit 17
led_init()Tạo device number, cdev, class, deviceThêm ioremap() và chọn chân output ở đầu
led_exit()Hủy theo thứ tự ngượcThêm tắt LED và iounmap() ở cuối
Nhánh lỗi của led_init()err_class, err_cdev, err_regionThêm err_map để iounmap()

Hình minh họa cho thấy đường đi của một lệnh ghi. Phần mới chỉ là hai tầng dưới cùng: writel() ghi vào địa chỉ ảo, MMU đổi sang địa chỉ vật lý 0xFE200000 cộng offset.

gpio_legacy_ioremap-hinh-06

Hàm led_read() giờ trả về mức điện áp thật của chân. Vì thế đọc lại on sau khi ghi on chứng tỏ chân đã lên mức cao, không chỉ là biến trong driver nhớ giá trị.

4.1.1. Ví dụ 2

Hai hàm led_set() và led_get() gom việc truy cập thanh ghi, nên led_write() và led_read() gần như giữ nguyên từ bài trước. Ở led_init(), ioremap() và cấu hình chân output được làm trước khi tạo device file, để khi /dev/led xuất hiện thì LED đã sẵn sàng và đang tắt.

File gpio_led_cdev.c:

// SPDX-License-Identifier: GPL-2.0
/*
 * gpio_led_cdev: /dev/led of the previous lesson, driving the real LED on GPIO17
 * through the GPIO registers instead of a variable.
 */
#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt

#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/io.h>
#include <linux/module.h>
#include <linux/sched.h>
#include <linux/string.h>
#include <linux/uaccess.h>

/* GPIO block of BCM2711 (Raspberry Pi 4). */
#define GPIO_PHYS	0xFE200000UL
#define GPIO_SIZE	0x100

/* Register offsets. */
#define GPFSEL1		0x04
#define GPSET0		0x1C
#define GPCLR0		0x28
#define GPLEV0		0x34

/* The LED is wired to GPIO17. */
#define LED_PIN		17

/* Virtual address of the mapped GPIO registers. */
static void __iomem *gpio_base;
/* Device number given by the kernel. */
static dev_t devno;
/* Links the device number to the file operations. */
static struct cdev led_cdev;
/* Class that makes /dev/led appear. */
static struct class *led_class;

/* Step 2: drive the LED pin through GPSET0 (on) or GPCLR0 (off). */
static void led_set(bool on)
{
	writel(1U << LED_PIN, gpio_base + (on ? GPSET0 : GPCLR0));
}

/* Read the real pin level from GPLEV0. */
static bool led_get(void)
{
	return (readl(gpio_base + GPLEV0) >> LED_PIN) & 1;
}

static int led_open(struct inode *inode, struct file *filp)
{
	pr_info("open by %s pid=%d\n", current->comm, current->pid);

	return 0;
}

static int led_release(struct inode *inode, struct file *filp)
{
	pr_info("release\n");

	return 0;
}

static ssize_t led_read(struct file *filp, char __user *user_buf,
			size_t count, loff_t *ppos)
{
	/* Step 3: report the real pin level, not a variable. */
	const char *state = led_get() ? "on\n" : "off\n";

	return simple_read_from_buffer(user_buf, count, ppos, state,
				       strlen(state));
}

static ssize_t led_write(struct file *filp, const char __user *user_buf,
			 size_t count, loff_t *ppos)
{
	char kbuf[8];
	size_t len = min(count, sizeof(kbuf) - 1);

	/* Copy at most 7 bytes from the user. */
	if (copy_from_user(kbuf, user_buf, len))
		return -EFAULT;
	kbuf[len] = '\0';

	/* Accept "on" or "off", drive the pin, refuse anything else. */
	if (sysfs_streq(kbuf, "on"))
		led_set(true);
	else if (sysfs_streq(kbuf, "off"))
		led_set(false);
	else
		return -EINVAL;

	/* Log the level read back from the pin. */
	pr_info("LED %s\n", led_get() ? "ON" : "OFF");

	return count;
}

static const struct file_operations led_fops = {
	.owner = THIS_MODULE,
	.open = led_open,
	.release = led_release,
	.read = led_read,
	.write = led_write,
};

static int __init led_init(void)
{
	u32 fsel;
	int ret;

	/* Step 1: map the registers and make GPIO17 an output. */
	gpio_base = ioremap(GPIO_PHYS, GPIO_SIZE);
	if (!gpio_base)
		return -ENOMEM;
	fsel = readl(gpio_base + GPFSEL1);
	fsel &= ~(7U << ((LED_PIN % 10) * 3));
	fsel |= 1U << ((LED_PIN % 10) * 3);
	writel(fsel, gpio_base + GPFSEL1);

	/* The LED starts off. */
	led_set(false);

	ret = alloc_chrdev_region(&devno, 0, 1, "led");
	if (ret)
		goto err_map;

	cdev_init(&led_cdev, &led_fops);
	led_cdev.owner = THIS_MODULE;
	ret = cdev_add(&led_cdev, devno, 1);
	if (ret)
		goto err_region;

	led_class = class_create("led");
	if (IS_ERR(led_class)) {
		ret = PTR_ERR(led_class);
		goto err_cdev;
	}
	if (IS_ERR(device_create(led_class, NULL, devno, NULL, "led"))) {
		ret = -ENODEV;
		goto err_class;
	}

	pr_info("/dev/led ready, major=%d\n", MAJOR(devno));
	return 0;

	/* Undo the finished steps in reverse order. */
err_class:
	class_destroy(led_class);
err_cdev:
	cdev_del(&led_cdev);
err_region:
	unregister_chrdev_region(devno, 1);
err_map:
	/* Step 4: unmap the registers (new label). */
	iounmap(gpio_base);
	return ret;
}

static void __exit led_exit(void)
{
	device_destroy(led_class, devno);
	class_destroy(led_class);
	cdev_del(&led_cdev);
	unregister_chrdev_region(devno, 1);

	/* Step 4: turn the LED off, then unmap the registers. */
	led_set(false);
	iounmap(gpio_base);

	pr_info("unloaded\n");
}

module_init(led_init);
module_exit(led_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("DEVLINUX Academy");
MODULE_DESCRIPTION("/dev/led driving a real GPIO pin");

Nạp module rồi điều khiển LED bằng shell, giống bài trước, và đọc mức chân bằng pin 17:

$ insmod gpio_led_cdev.ko
$ ls -l /dev/led
crw-------    1 root     root      240,   0 Jan  1 00:00 /dev/led
$ pin 17
GPIO17=0
$ echo on > /dev/led
$ cat /dev/led
on
$ pin 17
GPIO17=1
$ echo off > /dev/led
$ cat /dev/led
off
$ pin 17
GPIO17=0
$ echo blink > /dev/led
sh: write error: Invalid argument
$ echo on > /dev/led
$ rmmod gpio_led_cdev
$ pin 17
GPIO17=0
$ dmesg | grep gpio_led_cdev
[    3.490374] gpio_led_cdev: loading out-of-tree module taints kernel.
[    3.501119] gpio_led_cdev: /dev/led ready, major=240
[    3.553299] gpio_led_cdev: open by sh pid=101
[    3.553628] gpio_led_cdev: LED ON
[    3.556004] gpio_led_cdev: release
[    3.567688] gpio_led_cdev: open by cat pid=108
[    3.569608] gpio_led_cdev: release
[    3.598504] gpio_led_cdev: open by sh pid=101
[    3.598685] gpio_led_cdev: LED OFF
[    3.598725] gpio_led_cdev: release
[    3.610832] gpio_led_cdev: open by cat pid=111
[    3.611090] gpio_led_cdev: release
[    3.640527] gpio_led_cdev: open by sh pid=101
[    3.640893] gpio_led_cdev: release
[    3.646220] gpio_led_cdev: open by sh pid=101
[    3.646331] gpio_led_cdev: LED ON
[    3.646368] gpio_led_cdev: release
[    3.661736] gpio_led_cdev: unloaded
  • Ngay sau insmod, pin 17 báo GPIO17=0: LED tắt trước khi /dev/led xuất hiện. echo on làm GPIO17=1 và cat đọc lại on, khớp dòng LED ON trong log. echo off làm GPIO17=0.
  • echo blink bị từ chối với Invalid argument vì led_write() trả -EINVAL: dòng open và release của lệnh này không có dòng LED ... vì driver từ chối trước khi đổi chân.
  • Lệnh echo on cuối cùng bật LED, rồi rmmod gọi led_exit() tắt LED: pin 17 báo GPIO17=0.

Module build cho kernel 6.12 của board ảo: từ Linux 6.4, class_create() không còn nhận tham số THIS_MODULE, nên code gọi class_create("led"). Kernel cũ hơn (như 5.4 của Ubuntu 20.04) cần dạng class_create(THIS_MODULE, "led").

5. Kết luận

  • Một thanh ghi có ba địa chỉ: bus (datasheet), physical (CPU), virtual (code kernel). Trên Pi 4, physical = bus - 0x7E000000 + 0xFE000000.
  • ioremap() ánh xạ địa chỉ vật lý của thanh ghi thành địa chỉ ảo, readl() và writel() truy cập qua địa chỉ đó, iounmap() giải phóng.
  • GPFSEL chọn chức năng chân (3 bit mỗi chân, 001 là output), GPSET và GPCLR bật tắt chân bằng cách ghi 1 vào bit tương ứng, GPLEV đọc mức chân.
  • Đặt ioremap() và cấu hình chân ở module_init, tắt LED và iounmap() ở module_exit thì insmod bật LED và rmmod tắt LED.
  • Ghép vào driver /dev/led chỉ cần thay chỗ đặt và đọc trạng thái LED bằng led_set() và led_get().
Bạn đã xem hết các bài học thử.